From f62de61eb8e6d22a1a20f70bc8fb679607b47128 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Tue, 13 Jan 2026 12:58:54 -0800 Subject: [PATCH 1/4] Demystify tsjs mode integration for Prebid.js --- README.md | 14 ++++----- SEQUENCE.md | 4 +-- crates/common/README.md | 2 +- crates/common/src/integrations/prebid.rs | 34 ++++++++++----------- crates/js/lib/src/core/config.ts | 2 +- crates/js/lib/src/core/request.ts | 38 +++++++++++++++--------- crates/js/lib/src/core/types.ts | 6 ++-- crates/js/lib/test/core/request.test.ts | 18 +++++------ docs/guide/api-reference.md | 22 +++++++------- docs/guide/error-reference.md | 2 +- docs/guide/integration-guide.md | 4 +-- docs/guide/integration_guide.md | 11 ++++--- docs/guide/integrations-overview.md | 6 ++-- docs/guide/what-is-trusted-server.md | 2 +- 14 files changed, 87 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index 49f1dc8..4a3bd76 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Trusted Server -:information_source: Trusted Server is an open-source, cloud based orchestration framework and runtime for publishers. It moves code execution and operations that traditionally occurs in browsers (via 3rd party JS) to secure, zero-cold-start [WASM](https://webassembly.org) binaries running in [WASI](https://github.com/WebAssembly/WASI) supported environments. It importantly gives publishers benefits such as: dramatically increasing control over how and who they share their data with (while maintaining user-privacy compliance), increasing revenue from inventory inside cookie restricted or non-JS environments, ability to serve all assets under 1st party context, and provides secure cryptographic functions to ensure trust across the programmatic ad ecosystem. +:information_source: Trusted Server is an open-source, cloud based orchestration framework and runtime for publishers. It moves code execution and operations that traditionally occurs in browsers (via external JS) to secure, zero-cold-start [WASM](https://webassembly.org) binaries running in [WASI](https://github.com/WebAssembly/WASI) supported environments. It importantly gives publishers benefits such as: dramatically increasing control over how and who they share their data with (while maintaining user-privacy compliance), increasing revenue from inventory inside cookie restricted or non-JS environments, ability to serve all assets under publisher context, and provides secure cryptographic functions to ensure trust across the programmatic ad ecosystem. Trusted Server is the new execution layer for the open-web, returning control of 1st party data, security, and overall user-experience back to publishers. @@ -210,11 +210,11 @@ Once configured, the following endpoints are available: :warning: Key rotation keeps both the new and previous key active to allow for graceful transitions. Deactivate old keys manually when no longer needed. -## First-Party Endpoints +## Ad and Proxy Endpoints -- `/first-party/ad` (GET): returns HTML for a single slot (`slot`, `w`, `h` query params). The server inspects returned creative HTML and rewrites: +- `/ad/render` (GET): returns HTML for a single slot (`slot`, `w`, `h` query params). The server inspects returned creative HTML and rewrites: - All absolute images and iframes to `/first-party/proxy?tsurl=&&tstoken=` (1×1 pixels are detected server‑side heuristically for logging). The `tstoken` is derived from encrypting the full target URL and hashing it. -- `/third-party/ad` (POST): accepts tsjs ad units and proxies to Prebid Server. +- `/ad/auction` (POST): accepts tsjs ad units and proxies to Prebid Server. - `/first-party/proxy` (GET): unified proxy for resources referenced by creatives. - Query params: - `tsurl`: Target URL without query (base URL) — required @@ -226,10 +226,10 @@ Once configured, the following endpoints are available: - Image responses: proxied; if content‑type is missing, sets `image/*`; logs likely 1×1 pixels via size/URL heuristics - Follows HTTP redirects (301/302/303/307/308) up to four hops, reapplying the forwarded synthetic ID and switching to `GET` after a 303; logs when the redirect limit is reached. - When forwarding to the target URL, no `tstoken` is included (it is not part of the target URL). -- Synthetic ID propagation: reads the trusted ID from the incoming cookie/header and appends `synthetic_id=` to the target URL sent to the third-party origin while preserving existing query strings. +- Synthetic ID propagation: reads the trusted ID from the incoming cookie/header and appends `synthetic_id=` to the target URL sent to the origin while preserving existing query strings. - Redirect following re-applies the identifier on each hop so downstream origins see a consistent ID even when assets bounce through intermediate trackers. -- `/first-party/click` (GET): first‑party click redirect handler for anchors and clickable areas. +- `/first-party/click` (GET): click redirect handler for anchors and clickable areas. - Query params: same as `/first-party/proxy` (uses `tsurl`, original params, `tstoken`). - Behavior: - Validates `tstoken` against the reconstructed full URL (same enc+SHA256 scheme). @@ -243,7 +243,7 @@ Notes - Rewriting uses `lol_html`. Only absolute and protocol‑relative URLs are rewritten; relative URLs are left unchanged. - For the proxy endpoint, the base URL is carried in `tsurl`, the original query parameters are preserved individually, and `tstoken` authenticates the reconstructed full URL. -- Synthetic identifiers are generated by `crates/common/src/synthetic.rs` and are surfaced in three places: publisher responses (headers + cookie), creative proxy target URLs (`synthetic_id` query param), and click redirect URLs. This ensures downstream integrations can correlate impressions and clicks without direct third-party cookies. +- Synthetic identifiers are generated by `crates/common/src/synthetic.rs` and are surfaced in three places: publisher responses (headers + cookie), creative proxy target URLs (`synthetic_id` query param), and click redirect URLs. This ensures downstream integrations can correlate impressions and clicks without direct cross-site cookies. ## Integration Modules diff --git a/SEQUENCE.md b/SEQUENCE.md index 235ef0e..c4bc28d 100644 --- a/SEQUENCE.md +++ b/SEQUENCE.md @@ -1,4 +1,4 @@ -# 🛡️ Trusted Server — First-Party Proxying Flow +# 🛡️ Trusted Server — Proxying Flow ## 🔄 System Flow Diagram @@ -80,7 +80,7 @@ sequenceDiagram activate TS activate PBS activate DSP - JS->>TS: GET /first-party/ad
(with signals) + JS->>TS: GET /ad/render
(with signals) TS->>PBS: POST /openrtb2/auction
(OpenRTB 2.x) PBS->>DSP: POST bid request DSP-->>PBS: 200 bid response diff --git a/crates/common/README.md b/crates/common/README.md index 4fa5fad..50ad8bc 100644 --- a/crates/common/README.md +++ b/crates/common/README.md @@ -1,6 +1,6 @@ # trusted-server-common -Utilities shared by Trusted Server components. This crate contains HTML/CSS rewriting helpers used to normalize ad creative assets to first‑party proxy endpoints. +Utilities shared by Trusted Server components. This crate contains HTML/CSS rewriting helpers used to normalize ad creative assets to proxy endpoints. ## Creative Rewriting diff --git a/crates/common/src/integrations/prebid.rs b/crates/common/src/integrations/prebid.rs index 701a325..d7bd979 100644 --- a/crates/common/src/integrations/prebid.rs +++ b/crates/common/src/integrations/prebid.rs @@ -26,8 +26,8 @@ use crate::settings::{IntegrationConfig, Settings}; use crate::synthetic::{generate_synthetic_id, get_or_generate_synthetic_id}; const PREBID_INTEGRATION_ID: &str = "prebid"; -const ROUTE_FIRST_PARTY_AD: &str = "/first-party/ad"; -const ROUTE_THIRD_PARTY_AD: &str = "/third-party/ad"; +const ROUTE_RENDER: &str = "/ad/render"; +const ROUTE_AUCTION: &str = "/ad/auction"; #[derive(Debug, Clone, Deserialize, Serialize, Validate)] pub struct PrebidIntegrationConfig { @@ -212,7 +212,7 @@ impl PrebidIntegration { } } - async fn handle_third_party_ad( + async fn handle_auction( &self, settings: &Settings, mut req: Request, @@ -223,7 +223,7 @@ impl PrebidIntegration { }, )?; - log::info!("/third-party/ad: received {} adUnits", body.ad_units.len()); + log::info!("/auction: received {} adUnits", body.ad_units.len()); for unit in &body.ad_units { if let Some(mt) = &unit.media_types { if let Some(banner) = &mt.banner { @@ -260,14 +260,14 @@ impl PrebidIntegration { .with_body(body)) } - async fn handle_first_party_ad( + async fn handle_render( &self, settings: &Settings, mut req: Request, ) -> Result> { let url = req.get_url_str(); let parsed = Url::parse(url).change_context(TrustedServerError::Prebid { - message: "Invalid first-party serve-ad URL".to_string(), + message: "Invalid render URL".to_string(), })?; let qp = parsed .query_pairs() @@ -357,8 +357,8 @@ impl IntegrationProxy for PrebidIntegration { fn routes(&self) -> Vec { let mut routes = vec![ - IntegrationEndpoint::get(ROUTE_FIRST_PARTY_AD), - IntegrationEndpoint::post(ROUTE_THIRD_PARTY_AD), + IntegrationEndpoint::get(ROUTE_RENDER), + IntegrationEndpoint::post(ROUTE_AUCTION), ]; // Register routes for script removal patterns @@ -381,11 +381,11 @@ impl IntegrationProxy for PrebidIntegration { let method = req.get_method().clone(); match method { - Method::GET if path == ROUTE_FIRST_PARTY_AD => { - self.handle_first_party_ad(settings, req).await + Method::GET if path == ROUTE_RENDER => { + self.handle_render(settings, req).await } - Method::POST if path == ROUTE_THIRD_PARTY_AD => { - self.handle_third_party_ad(settings, req).await + Method::POST if path == ROUTE_AUCTION => { + self.handle_auction(settings, req).await } // Serve empty JS for matching script patterns Method::GET if self.matches_script_pattern(&path) => self.handle_script_handler(), @@ -946,8 +946,8 @@ mod tests { let routes = integration.routes(); // Should include the default ad routes - assert!(routes.iter().any(|r| r.path == "/first-party/ad")); - assert!(routes.iter().any(|r| r.path == "/third-party/ad")); + assert!(routes.iter().any(|r| r.path == "/ad/render")); + assert!(routes.iter().any(|r| r.path == "/ad/auction")); // Should include default script removal patterns assert!(routes.iter().any(|r| r.path == "/prebid.js")); @@ -965,7 +965,7 @@ mod tests { let synthetic_id = "synthetic-123"; let fresh_id = "fresh-456"; - let mut req = Request::new(Method::POST, "https://edge.example/third-party/ad"); + let mut req = Request::new(Method::POST, "https://edge.example/auction"); req.set_header("Sec-GPC", "1"); enhance_openrtb_request(&mut request_json, synthetic_id, fresh_id, &settings, &req) @@ -1158,8 +1158,8 @@ server_url = "https://prebid.example" assert_eq!(routes.len(), 6); // Verify ad routes - assert!(routes.iter().any(|r| r.path == "/first-party/ad")); - assert!(routes.iter().any(|r| r.path == "/third-party/ad")); + assert!(routes.iter().any(|r| r.path == "/ad/render")); + assert!(routes.iter().any(|r| r.path == "/ad/auction")); // Verify script pattern routes assert!(routes.iter().any(|r| r.path == "/prebid.js")); diff --git a/crates/js/lib/src/core/config.ts b/crates/js/lib/src/core/config.ts index c06f081..918af65 100644 --- a/crates/js/lib/src/core/config.ts +++ b/crates/js/lib/src/core/config.ts @@ -3,7 +3,7 @@ import { log, LogLevel } from './log'; import type { Config } from './types'; import { RequestMode } from './types'; -let CONFIG: Config = { mode: RequestMode.FirstParty }; +let CONFIG: Config = { mode: RequestMode.Render }; // Merge publisher-provided config and adjust the log level accordingly. export function setConfig(cfg: Config): void { diff --git a/crates/js/lib/src/core/request.ts b/crates/js/lib/src/core/request.ts index 98e04a3..03799af 100644 --- a/crates/js/lib/src/core/request.ts +++ b/crates/js/lib/src/core/request.ts @@ -1,4 +1,4 @@ -// Request orchestration for tsjs: fires first-party iframe loads or third-party fetches. +// Request orchestration for tsjs: fires render (iframe) or auction (JSON) requests. import { delay } from '../shared/async'; import { log } from './log'; @@ -10,7 +10,7 @@ import type { RequestAdsCallback, RequestAdsOptions } from './types'; // getHighestCpmBids is provided by the Prebid extension (shim) to mirror Prebid's API -// Entry point matching Prebid's requestBids signature; decides first/third-party mode. +// Entry point matching Prebid's requestBids signature; decides render/auction mode. export function requestAds( callbackOrOpts?: RequestAdsCallback | RequestAdsOptions, maybeOpts?: RequestAdsOptions @@ -25,28 +25,38 @@ export function requestAds( callback = opts?.bidsBackHandler; } - const mode: RequestMode = (getConfig().mode as RequestMode | undefined) ?? RequestMode.FirstParty; + const mode = resolveRequestMode(getConfig().mode); log.info('requestAds: called', { hasCallback: typeof callback === 'function', mode }); try { const adUnits = getAllUnits(); const payload = { adUnits, config: {} }; log.debug('requestAds: payload', { units: adUnits.length }); - if (mode === RequestMode.FirstParty) void requestAdsFirstParty(adUnits); - else requestAdsThirdParty(payload); + if (mode === RequestMode.Render) void requestAdsRender(adUnits); + else requestAdsAuction(payload); // Synchronously invoke callback to match test expectations try { if (callback) callback(); } catch { /* ignore callback errors */ } - // network handled in requestAdsThirdParty; no-op here + // network handled in requestAdsAuction; no-op here } catch { log.warn('requestAds: failed to initiate'); } } -// Create per-slot first-party iframe requests served directly from the edge. -async function requestAdsFirstParty(adUnits: ReadonlyArray<{ code: string }>) { +function resolveRequestMode(mode: unknown): RequestMode { + if (mode === RequestMode.Render || mode === RequestMode.Auction) { + return mode; + } + if (mode !== undefined) { + log.warn('requestAds: invalid mode; defaulting to render', { mode }); + } + return RequestMode.Render; +} + +// Create per-slot iframe requests served directly from the edge via /ad/render. +async function requestAdsRender(adUnits: ReadonlyArray<{ code: string }>) { for (const unit of adUnits) { const size = (firstSize(unit) ?? [300, 250]) as readonly [number, number]; const slotId = unit.code; @@ -60,12 +70,12 @@ async function requestAdsFirstParty(adUnits: ReadonlyArray<{ code: string }>) { width: size[0], height: size[1], }); - iframe.src = `/first-party/ad?slot=${encodeURIComponent(slotId)}&w=${encodeURIComponent(String(size[0]))}&h=${encodeURIComponent(String(size[1]))}`; + iframe.src = `/ad/render?slot=${encodeURIComponent(slotId)}&w=${encodeURIComponent(String(size[0]))}&h=${encodeURIComponent(String(size[1]))}`; return; } if (attemptsRemaining <= 0) { - log.warn('requestAds(firstParty): slot not found; skipping iframe', { slotId }); + log.warn('requestAds(render): slot not found; skipping iframe', { slotId }); return; } @@ -88,18 +98,18 @@ async function requestAdsFirstParty(adUnits: ReadonlyArray<{ code: string }>) { } } -// Fire a JSON POST to the third-party ad endpoint and render returned creatives. -function requestAdsThirdParty(payload: { adUnits: unknown[]; config: unknown }) { +// Fire a JSON POST to /ad/auction and render returned creatives. +function requestAdsAuction(payload: { adUnits: unknown[]; config: unknown }) { // Render simple placeholders immediately so pages have content renderAllAdUnits(); if (typeof fetch !== 'function') { log.warn('requestAds: fetch not available; nothing to render'); return; } - log.info('requestAds: sending request to /third-party/ad', { + log.info('requestAds: sending request to /ad/auction', { units: (payload.adUnits || []).length, }); - void fetch('/third-party/ad', { + void fetch('/ad/auction', { method: 'POST', headers: { 'content-type': 'application/json' }, credentials: 'same-origin', diff --git a/crates/js/lib/src/core/types.ts b/crates/js/lib/src/core/types.ts index 3bf1b49..e65641a 100644 --- a/crates/js/lib/src/core/types.ts +++ b/crates/js/lib/src/core/types.ts @@ -37,14 +37,14 @@ export interface TsjsApi { } export enum RequestMode { - FirstParty = 'firstParty', - ThirdParty = 'thirdParty', + Render = 'render', + Auction = 'auction', } export interface Config { debug?: boolean; logLevel?: 'silent' | 'error' | 'warn' | 'info' | 'debug'; - /** Select ad serving mode. Default is RequestMode.FirstParty. */ + /** Select ad serving mode. Default is RequestMode.Render. */ mode?: RequestMode; // Extendable for future fields [key: string]: unknown; diff --git a/crates/js/lib/test/core/request.test.ts b/crates/js/lib/test/core/request.test.ts index 54b6264..79eeeb5 100644 --- a/crates/js/lib/test/core/request.test.ts +++ b/crates/js/lib/test/core/request.test.ts @@ -34,7 +34,7 @@ describe('request.requestAds', () => { document.body.innerHTML = '
'; addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); - setConfig({ mode: 'thirdParty' } as any); + setConfig({ mode: 'auction' } as any); requestAds(); // wait microtasks @@ -66,7 +66,7 @@ describe('request.requestAds', () => { const { requestAds } = await import('../../src/core/request'); addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); - setConfig({ mode: 'thirdParty' } as any); + setConfig({ mode: 'auction' } as any); requestAds(); await Promise.resolve(); @@ -92,7 +92,7 @@ describe('request.requestAds', () => { const { requestAds } = await import('../../src/core/request'); addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); - setConfig({ mode: 'thirdParty' } as any); + setConfig({ mode: 'auction' } as any); requestAds(); await Promise.resolve(); @@ -102,7 +102,7 @@ describe('request.requestAds', () => { expect(renderMock).not.toHaveBeenCalled(); }); - it('inserts an iframe per ad unit with correct src (firstParty)', async () => { + it('inserts an iframe per ad unit with correct src (render mode)', async () => { const { addAdUnits } = await import('../../src/core/registry'); const { setConfig } = await import('../../src/core/config'); const { requestAds } = await import('../../src/core/request'); @@ -112,8 +112,8 @@ describe('request.requestAds', () => { div.id = 'slot1'; document.body.appendChild(div); - // Configure first-party mode explicitly - setConfig({ mode: 'firstParty' } as any); + // Configure render mode explicitly + setConfig({ mode: 'render' } as any); // Add an ad unit and request addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); @@ -122,18 +122,18 @@ describe('request.requestAds', () => { // Verify iframe was inserted with expected src const iframe = document.querySelector('#slot1 iframe') as HTMLIFrameElement | null; expect(iframe).toBeTruthy(); - expect(iframe!.getAttribute('src')).toContain('/first-party/ad?'); + expect(iframe!.getAttribute('src')).toContain('/ad/render?'); expect(iframe!.getAttribute('src')).toContain('slot=slot1'); expect(iframe!.getAttribute('src')).toContain('w=300'); expect(iframe!.getAttribute('src')).toContain('h=250'); }); - it('skips iframe insertion when slot is missing (firstParty)', async () => { + it('skips iframe insertion when slot is missing (render mode)', async () => { const { addAdUnits } = await import('../../src/core/registry'); const { setConfig } = await import('../../src/core/config'); const { requestAds } = await import('../../src/core/request'); - setConfig({ mode: 'firstParty' } as any); + setConfig({ mode: 'render' } as any); addAdUnits({ code: 'missing-slot', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); requestAds(); diff --git a/docs/guide/api-reference.md b/docs/guide/api-reference.md index eabd1fa..535ab49 100644 --- a/docs/guide/api-reference.md +++ b/docs/guide/api-reference.md @@ -11,9 +11,9 @@ Quick reference for all Trusted Server HTTP endpoints. --- -## First-Party Endpoints +## Ad and Proxy Endpoints -### GET /first-party/ad +### GET /ad/render Server-side ad rendering endpoint. Returns complete HTML for a single ad slot. @@ -26,11 +26,11 @@ Server-side ad rendering endpoint. Returns complete HTML for a single ad slot. **Response:** - **Content-Type:** `text/html; charset=utf-8` -- **Body:** Complete HTML creative with first-party proxying applied +- **Body:** Complete HTML creative with proxy rewrites applied **Example:** ```bash -curl "https://edge.example.com/first-party/ad?slot=header-banner&w=728&h=90" +curl "https://edge.example.com/ad/render?slot=header-banner&w=728&h=90" ``` **Response Headers:** @@ -44,7 +44,7 @@ curl "https://edge.example.com/first-party/ad?slot=header-banner&w=728&h=90" --- -### POST /third-party/ad +### POST /ad/auction Client-side auction endpoint for TSJS library. @@ -88,7 +88,7 @@ Client-side auction endpoint for TSJS library. **Example:** ```bash -curl -X POST https://edge.example.com/third-party/ad \ +curl -X POST https://edge.example.com/ad/auction \ -H "Content-Type: application/json" \ -d '{"adUnits":[{"code":"banner","mediaTypes":{"banner":{"sizes":[[300,250]]}}}]}' ``` @@ -164,7 +164,7 @@ curl -I "https://edge.example.com/first-party/click?tsurl=https://advertiser.com ### GET/POST /first-party/sign -URL signing endpoint. Returns signed first-party proxy URL for a given target URL. +URL signing endpoint. Returns signed proxy URL for a given target URL. **Request Methods:** GET or POST @@ -415,11 +415,11 @@ See [Configuration](./configuration.md) for TSJS build options. ### Prebid Integration -#### GET /first-party/ad -See [First-Party Endpoints](#get-first-party-ad) above. +#### GET /ad/render +See [Ad and Proxy Endpoints](#get-ad-render) above. -#### POST /third-party/ad -See [First-Party Endpoints](#post-third-party-ad) above. +#### POST /ad/auction +See [Ad and Proxy Endpoints](#post-ad-auction) above. #### GET /prebid.js, /prebid.min.js, etc. (Script Override) Returns empty JavaScript to override Prebid.js scripts when the Prebid integration is enabled. By default, exact requests to `/prebid.js`, `/prebid.min.js`, `/prebidjs.js`, or `/prebidjs.min.js` will be intercepted and served an empty script. diff --git a/docs/guide/error-reference.md b/docs/guide/error-reference.md index a7c9c43..18f9297 100644 --- a/docs/guide/error-reference.md +++ b/docs/guide/error-reference.md @@ -595,7 +595,7 @@ fastly log-tail fastly compute serve # Test endpoint -curl http://localhost:7676/first-party/ad?slot=test&w=300&h=250 +curl http://localhost:7676/ad/render?slot=test&w=300&h=250 ``` --- diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index f96a9a3..cb38b7c 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -256,7 +256,7 @@ Two built-in integrations demonstrate how the framework pieces fit together: ### Prebid -**Purpose**: Production Prebid Server bridge that owns `/first-party/ad` & `/third-party/ad`, injects synthetic IDs, rewrites creatives/notification URLs, and removes publisher-supplied Prebid scripts because the shim already ships in the unified TSJS build. +**Purpose**: Production Prebid Server bridge that owns `/ad/render` & `/ad/auction`, injects synthetic IDs, rewrites creatives/notification URLs, and removes publisher-supplied Prebid scripts because the shim already ships in the unified TSJS build. **Key files**: - `crates/common/src/integrations/prebid.rs` - Rust implementation @@ -283,7 +283,7 @@ Tests or scaffolding can inject configs by calling `settings.integrations.insert **2. Routes Owned by the Integration** -`IntegrationProxy::routes` declares the `/integrations/prebid/first-party/ad` (GET) and `/integrations/prebid/third-party/ad` (POST) endpoints. Both handlers share helpers that shape OpenRTB payloads, inject synthetic IDs + geo/request-signing context, forward requests via `ensure_backend_from_url`, and run the HTML creative rewrites before responding. All routes are properly namespaced under `/integrations/prebid/` to follow the integration routing pattern. +`IntegrationProxy::routes` declares the `/ad/render` (GET) and `/ad/auction` (POST) endpoints. Both handlers share helpers that shape OpenRTB payloads, inject synthetic IDs + geo/request-signing context, forward requests via `ensure_backend_from_url`, and run the HTML creative rewrites before responding. These routes are intentionally un-namespaced to match the TSJS client. **3. HTML Rewrites Through the Registry** diff --git a/docs/guide/integration_guide.md b/docs/guide/integration_guide.md index 0054909..183ec18 100644 --- a/docs/guide/integration_guide.md +++ b/docs/guide/integration_guide.md @@ -298,7 +298,7 @@ Two built-in integrations demonstrate how the framework pieces fit together: | Integration | Purpose | Key files | | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | `testlight` | Sample partner stub showing request proxying, attribute rewrites, and asset injection. | `crates/common/src/integrations/testlight.rs`, `crates/js/lib/src/integrations/testlight.ts` | -| `prebid` | Production Prebid Server bridge that owns `/first-party/ad` & `/third-party/ad`, injects synthetic IDs, rewrites creatives/notification URLs, and removes publisher-supplied Prebid scripts because the shim already ships in the unified TSJS build. | `crates/common/src/integrations/prebid.rs`, `crates/js/lib/src/ext/prebidjs.ts` | +| `prebid` | Production Prebid Server bridge that owns `/ad/render` & `/ad/auction`, injects synthetic IDs, rewrites creatives/notification URLs, and removes publisher-supplied Prebid scripts because the shim already ships in the unified TSJS build. | `crates/common/src/integrations/prebid.rs`, `crates/js/lib/src/ext/prebidjs.ts` | ### Example: Prebid integration @@ -323,11 +323,10 @@ Prebid applies the same steps outlined above with a few notable patterns: other integrations use. 2. **Routes owned by the integration** – `IntegrationProxy::routes` declares the - `/integrations/prebid/first-party/ad` (GET) and `/integrations/prebid/third-party/ad` (POST) - endpoints. Both handlers share helpers that shape OpenRTB payloads, inject synthetic IDs + - geo/request-signing context, forward requests via `ensure_backend_from_url`, and run the HTML - creative rewrites before responding. All routes are properly namespaced under - `/integrations/prebid/` to follow the integration routing pattern. + `/ad/render` (GET) and `/ad/auction` (POST) endpoints. Both handlers share helpers that shape + OpenRTB payloads, inject synthetic IDs + geo/request-signing context, forward requests via + `ensure_backend_from_url`, and run the HTML creative rewrites before responding. These routes + are intentionally un-namespaced to match the TSJS client. 3. **HTML rewrites through the registry** – When the integration is enabled, the `IntegrationAttributeRewriter` removes any `"#, + mode_value + ) +} + #[derive(Debug, Clone, Deserialize, Serialize, Validate)] pub struct PrebidIntegrationConfig { #[serde(default = "default_enabled")] @@ -43,6 +62,9 @@ pub struct PrebidIntegrationConfig { pub bidders: Vec, #[serde(default)] pub debug: bool, + /// Optional default mode to enqueue when injecting the unified bundle. + #[serde(default)] + pub mode: Option, /// Patterns to match Prebid script URLs for serving empty JS. /// Supports suffix matching (e.g., "/prebid.min.js" matches any path ending with that) /// and wildcard patterns (e.g., "/static/prebid/*" matches paths under that prefix). @@ -344,7 +366,8 @@ pub fn register(settings: &Settings) -> Option { Some( IntegrationRegistration::builder(PREBID_INTEGRATION_ID) .with_proxy(integration.clone()) - .with_attribute_rewriter(integration) + .with_attribute_rewriter(integration.clone()) + .with_head_injector(integration) .build(), ) } @@ -415,6 +438,19 @@ impl IntegrationAttributeRewriter for PrebidIntegration { } } +impl IntegrationHeadInjector for PrebidIntegration { + fn integration_id(&self) -> &'static str { + PREBID_INTEGRATION_ID + } + + fn head_inserts(&self, _ctx: &IntegrationHtmlContext<'_>) -> Vec { + self.config + .mode + .map(|mode| vec![config_script_tag(mode)]) + .unwrap_or_default() + } +} + fn build_openrtb_from_ts( req: &AdRequest, settings: &Settings, @@ -792,6 +828,7 @@ mod tests { timeout_ms: 1000, bidders: vec!["exampleBidder".to_string()], debug: false, + mode: None, script_patterns: default_script_patterns(), } } @@ -1163,4 +1200,55 @@ server_url = "https://prebid.example" assert!(routes.iter().any(|r| r.path == "/prebidjs.js")); assert!(routes.iter().any(|r| r.path == "/prebidjs.min.js")); } + + #[test] + fn config_script_tag_generates_render_mode() { + let tag = config_script_tag(Mode::Render); + assert!(tag.starts_with("")); + assert!(tag.contains(r#"mode:"render""#)); + assert!(tag.contains("tsjs.setConfig")); + assert!(tag.contains("tsjs.que.push")); + } + + #[test] + fn config_script_tag_generates_auction_mode() { + let tag = config_script_tag(Mode::Auction); + assert!(tag.starts_with("")); + assert!(tag.contains(r#"mode:"auction""#)); + assert!(tag.contains("tsjs.setConfig")); + } + + #[test] + fn head_injector_returns_empty_when_mode_not_set() { + let integration = PrebidIntegration::new(base_config()); + let ctx = IntegrationHtmlContext { + request_host: "pub.example", + request_scheme: "https", + origin_host: "origin.example", + document_state: &Default::default(), + }; + let inserts = integration.head_inserts(&ctx); + assert!( + inserts.is_empty(), + "should not inject config when mode is None" + ); + } + + #[test] + fn head_injector_returns_config_script_when_mode_set() { + let mut config = base_config(); + config.mode = Some(Mode::Auction); + let integration = PrebidIntegration::new(config); + let ctx = IntegrationHtmlContext { + request_host: "pub.example", + request_scheme: "https", + origin_host: "origin.example", + document_state: &Default::default(), + }; + let inserts = integration.head_inserts(&ctx); + assert_eq!(inserts.len(), 1); + assert!(inserts[0].contains(r#"mode:"auction""#)); + } } diff --git a/crates/common/src/integrations/registry.rs b/crates/common/src/integrations/registry.rs index 819890d..734586a 100644 --- a/crates/common/src/integrations/registry.rs +++ b/crates/common/src/integrations/registry.rs @@ -357,6 +357,14 @@ pub trait IntegrationHtmlPostProcessor: Send + Sync { fn post_process(&self, html: &mut String, ctx: &IntegrationHtmlContext<'_>) -> bool; } +/// Trait for integration-provided HTML head injections. +pub trait IntegrationHeadInjector: Send + Sync { + /// Identifier for logging/diagnostics. + fn integration_id(&self) -> &'static str; + /// Return HTML snippets to insert at the start of ``. + fn head_inserts(&self, ctx: &IntegrationHtmlContext<'_>) -> Vec; +} + /// Registration payload returned by integration builders. pub struct IntegrationRegistration { pub integration_id: &'static str, @@ -364,6 +372,7 @@ pub struct IntegrationRegistration { pub attribute_rewriters: Vec>, pub script_rewriters: Vec>, pub html_post_processors: Vec>, + pub head_injectors: Vec>, } impl IntegrationRegistration { @@ -386,6 +395,7 @@ impl IntegrationRegistrationBuilder { attribute_rewriters: Vec::new(), script_rewriters: Vec::new(), html_post_processors: Vec::new(), + head_injectors: Vec::new(), }, } } @@ -420,6 +430,12 @@ impl IntegrationRegistrationBuilder { self } + #[must_use] + pub fn with_head_injector(mut self, injector: Arc) -> Self { + self.registration.head_injectors.push(injector); + self + } + #[must_use] pub fn build(self) -> IntegrationRegistration { self.registration @@ -441,6 +457,7 @@ struct IntegrationRegistryInner { html_rewriters: Vec>, script_rewriters: Vec>, html_post_processors: Vec>, + head_injectors: Vec>, } impl Default for IntegrationRegistryInner { @@ -455,6 +472,7 @@ impl Default for IntegrationRegistryInner { html_rewriters: Vec::new(), script_rewriters: Vec::new(), html_post_processors: Vec::new(), + head_injectors: Vec::new(), } } } @@ -539,6 +557,9 @@ impl IntegrationRegistry { inner .html_post_processors .extend(registration.html_post_processors.into_iter()); + inner + .head_injectors + .extend(registration.head_injectors.into_iter()); } } @@ -622,6 +643,18 @@ impl IntegrationRegistry { self.inner.html_post_processors.clone() } + /// Collect HTML snippets for insertion at the start of ``. + pub fn head_inserts(&self, ctx: &IntegrationHtmlContext<'_>) -> Vec { + let mut inserts = Vec::new(); + for injector in &self.inner.head_injectors { + let mut next = injector.head_inserts(ctx); + if !next.is_empty() { + inserts.append(&mut next); + } + } + inserts + } + /// Provide a snapshot of registered integrations and their hooks. pub fn registered_integrations(&self) -> Vec { let mut map: BTreeMap<&'static str, IntegrationMetadata> = BTreeMap::new(); @@ -668,6 +701,29 @@ impl IntegrationRegistry { html_rewriters: attribute_rewriters, script_rewriters, html_post_processors: Vec::new(), + head_injectors: Vec::new(), + }), + } + } + + #[cfg(test)] + pub fn from_rewriters_with_head_injectors( + attribute_rewriters: Vec>, + script_rewriters: Vec>, + head_injectors: Vec>, + ) -> Self { + Self { + inner: Arc::new(IntegrationRegistryInner { + get_router: Router::new(), + post_router: Router::new(), + put_router: Router::new(), + delete_router: Router::new(), + patch_router: Router::new(), + routes: Vec::new(), + html_rewriters: attribute_rewriters, + script_rewriters, + html_post_processors: Vec::new(), + head_injectors, }), } } @@ -711,6 +767,7 @@ impl IntegrationRegistry { html_rewriters: Vec::new(), script_rewriters: Vec::new(), html_post_processors: Vec::new(), + head_injectors: Vec::new(), }), } } diff --git a/docs/guide/configuration-reference.md b/docs/guide/configuration-reference.md index db4d7ec..3366973 100644 --- a/docs/guide/configuration-reference.md +++ b/docs/guide/configuration-reference.md @@ -598,6 +598,7 @@ All integrations support: | `timeout_ms` | Integer | `1000` | Request timeout in milliseconds | | `bidders` | Array[String] | `[]` | List of enabled bidders | | `debug` | Boolean | `false` | Enable debug logging | +| `mode` | String | None | Default TSJS request mode when Prebid is enabled (`render` or `auction`) | | `script_patterns` | Array[String] | See below | Patterns for removing Prebid script tags and intercepting requests | **Default `script_patterns`**: @@ -615,6 +616,7 @@ server_url = "https://prebid-server.example/openrtb2/auction" timeout_ms = 1200 bidders = ["kargo", "rubicon", "appnexus", "openx"] debug = false +mode = "auction" # script_patterns = ["/static/prebid/*"] # Optional: restrict to specific path ``` @@ -625,6 +627,7 @@ TRUSTED_SERVER__INTEGRATIONS__PREBID__SERVER_URL=https://prebid.example/auction TRUSTED_SERVER__INTEGRATIONS__PREBID__TIMEOUT_MS=1200 TRUSTED_SERVER__INTEGRATIONS__PREBID__BIDDERS=kargo,rubicon,appnexus TRUSTED_SERVER__INTEGRATIONS__PREBID__DEBUG=false +TRUSTED_SERVER__INTEGRATIONS__PREBID__MODE=auction TRUSTED_SERVER__INTEGRATIONS__PREBID__SCRIPT_PATTERNS__0=/prebid.js TRUSTED_SERVER__INTEGRATIONS__PREBID__SCRIPT_PATTERNS__1=/prebid.min.js ``` diff --git a/docs/guide/environment-variables.md b/docs/guide/environment-variables.md index ce4b5a6..7162d04 100644 --- a/docs/guide/environment-variables.md +++ b/docs/guide/environment-variables.md @@ -204,6 +204,9 @@ TRUSTED_SERVER__INTEGRATIONS__PREBID__BIDDERS="appnexus,rubicon,openx" # Enable debug logging TRUSTED_SERVER__INTEGRATIONS__PREBID__DEBUG=false +# Default tsjs mode when Prebid integration is enabled (optional) +TRUSTED_SERVER__INTEGRATIONS__PREBID__MODE="auction" # or "render" + # Script patterns to remove Prebid tags and serve empty JS (indexed format) # Default patterns match common Prebid filenames at exact paths TRUSTED_SERVER__INTEGRATIONS__PREBID__SCRIPT_PATTERNS__0="/prebid.js" @@ -220,6 +223,7 @@ server_url = "https://prebid-server.example.com" timeout_ms = 1000 bidders = ["appnexus", "rubicon", "openx"] debug = false +mode = "auction" script_patterns = ["/prebid.js", "/prebid.min.js", "/prebidjs.js", "/prebidjs.min.js"] ``` diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index cb38b7c..d5fc921 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -210,6 +210,8 @@ 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 `