Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ both runtime behavior and build/tooling changes.
| `crates/trusted-server-core/src/tsjs.rs` | Script tag generation with module IDs |
| `crates/trusted-server-core/src/html_processor.rs` | Injects `<script>` at `<head>` start |
| `crates/trusted-server-core/src/publisher.rs` | `/static/tsjs=` handler, concatenates modules |
| `crates/trusted-server-core/src/edge_cookie.rs` | Edge Cookie (EC) ID generation |
| `crates/trusted-server-core/src/ec/` | EC identity subsystem (generation, consent, cookies) |
| `crates/trusted-server-core/src/cookies.rs` | Cookie handling |
| `crates/trusted-server-core/src/consent/mod.rs` | GDPR and broader consent management |
| `crates/trusted-server-core/src/http_util.rs` | HTTP abstractions and request utilities |
Expand Down
5 changes: 3 additions & 2 deletions PUBLISHER_IDS_AUDIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@ This document lists all publisher-specific IDs and configurations found in the c
- `cookie_domain = ".test-publisher.com"` (line 3)
- `origin_url = "https://origin.test-publisher.com"` (line 4)

**KV Store Names:**
*(Removed — `counter_store` and `opid_store` were removed in the EC rename; they were vestigial from the template-based SyntheticID generation.)*
**KV Store Names (user-specific):**
- `counter_store = "jevans_synth_id_counter"` (line 24)
- `opid_store = "jevans_synth_id_opid"` (line 25)

## Hardcoded in Source Code

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
[local_server.backends]

[local_server.kv_stores]
[[local_server.kv_stores.creative_store]]
# These inline placeholders satisfy Viceroy's local KV configuration
# requirements without exercising KV-backed application behavior.
[[local_server.kv_stores.creative_store]]
key = "placeholder"
data = "placeholder"

Expand Down
271 changes: 137 additions & 134 deletions crates/js/lib/package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion crates/js/lib/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"eslint": "^9.10.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsdoc": "^62.8.0",
"eslint-plugin-jsdoc": "^62.5.4",
"eslint-plugin-unicorn": "^62.0.0",
"jsdom": "^28.0.0",
"prettier": "^3.2.5",
Expand Down
13 changes: 6 additions & 7 deletions crates/js/lib/src/core/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,15 @@ import NORMALIZE_CSS from './styles/normalize.css?inline';
import IFRAME_TEMPLATE from './templates/iframe.html?raw';

// Sandbox permissions granted to creative iframes.
// Ad creatives routinely contain scripts for tracking, click handling, and
// viewability measurement, so allow-scripts and allow-same-origin are required
// for creatives to render correctly. Server-side sanitization is the primary
// defense against malicious markup; the sandbox provides defense-in-depth.
// Notably absent:
// allow-scripts, allow-same-origin — prevent JS execution and same-origin
// access, which are the primary attack vectors for malicious creatives.
// allow-forms — server-side sanitization strips <form> elements, so form
// submission from creatives is not a supported use case. Omitting this token
// is consistent with that server-side policy and reduces the attack surface.
const CREATIVE_SANDBOX_TOKENS = [
'allow-forms',
'allow-popups',
'allow-popups-to-escape-sandbox',
'allow-same-origin',
'allow-scripts',
'allow-top-navigation-by-user-activation',
] as const;

Expand Down
7 changes: 0 additions & 7 deletions crates/js/lib/src/integrations/prebid/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,13 +269,6 @@ export function installPrebidNpm(config?: Partial<PrebidNpmConfig>): typeof pbjs
} else {
unit.bids.push({ bidder: ADAPTER_CODE, params: tsParams });
}

// Remove server-side bidder entries — they are now handled via the
// trustedServer adapter. Only keep client-side bidders (which run via
// their native Prebid.js adapters) and the trustedServer bid itself.
unit.bids = unit.bids.filter(
(b) => b.bidder === ADAPTER_CODE || clientSideBidders.has(b.bidder ?? '')
);
}

// Ensure the trustedServer adapter is allowed to return bids under any
Expand Down
6 changes: 3 additions & 3 deletions crates/js/lib/test/core/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,12 @@ describe('render', () => {
expect(iframe.srcdoc).toContain('<span>ad</span>');
expect(div.querySelector('iframe')).toBe(iframe);
const sandbox = iframe.getAttribute('sandbox') ?? '';
expect(sandbox).toContain('allow-forms');
expect(sandbox).not.toContain('allow-forms');
expect(sandbox).toContain('allow-popups');
expect(sandbox).toContain('allow-popups-to-escape-sandbox');
expect(sandbox).toContain('allow-top-navigation-by-user-activation');
expect(sandbox).toContain('allow-same-origin');
expect(sandbox).toContain('allow-scripts');
expect(sandbox).not.toContain('allow-same-origin');
expect(sandbox).not.toContain('allow-scripts');
});

it('preserves dollar sequences when building the creative document', async () => {
Expand Down
67 changes: 24 additions & 43 deletions crates/js/lib/test/integrations/prebid/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,13 +411,14 @@ describe('prebid/installPrebidNpm', () => {
];
pbjs.requestBids({ adUnits } as any);

// Each ad unit should only have trustedServer — original bidders are absorbed
// Each ad unit should have trustedServer added
for (const unit of adUnits) {
expect(unit.bids).toHaveLength(1);
expect(unit.bids[0].bidder).toBe('trustedServer');
const hasTsBidder = unit.bids.some((b: any) => b.bidder === 'trustedServer');
expect(hasTsBidder).toBe(true);
}

expect(adUnits[0].bids[0].params.bidderParams).toEqual({ appnexus: {} });
const trustedServerBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer');
expect(trustedServerBid.params.bidderParams).toEqual({ appnexus: {} });

// Should call through to original requestBids
expect(mockRequestBids).toHaveBeenCalled();
Expand All @@ -433,7 +434,7 @@ describe('prebid/installPrebidNpm', () => {
expect(tsCount).toBe(1);
});

it('captures per-bidder params on trustedServer bid and removes originals', () => {
it('captures per-bidder params on trustedServer bid', () => {
const pbjs = installPrebidNpm();

const adUnits = [
Expand All @@ -446,10 +447,8 @@ describe('prebid/installPrebidNpm', () => {
];
pbjs.requestBids({ adUnits } as any);

// Only trustedServer should remain — original bidders are absorbed
expect(adUnits[0].bids).toHaveLength(1);
const trustedServerBid = adUnits[0].bids[0] as any;
expect(trustedServerBid.bidder).toBe('trustedServer');
const trustedServerBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer');
expect(trustedServerBid).toBeDefined();
expect(trustedServerBid.params.bidderParams).toEqual({
appnexus: { placementId: 123 },
rubicon: { accountId: 'abc' },
Expand Down Expand Up @@ -483,12 +482,11 @@ describe('prebid/installPrebidNpm', () => {
];
pbjs.requestBids({ adUnits } as any);

// Original kargo bids should be removed, only trustedServer remains
expect(adUnits[0].bids).toHaveLength(1);
expect(adUnits[0].bids[0].params.zone).toBe('header');
const tsBid0 = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any;
expect(tsBid0.params.zone).toBe('header');

expect(adUnits[1].bids).toHaveLength(1);
expect(adUnits[1].bids[0].params.zone).toBe('fixed_bottom');
const tsBid1 = adUnits[1].bids.find((b: any) => b.bidder === 'trustedServer') as any;
expect(tsBid1.params.zone).toBe('fixed_bottom');
});

it('omits zone when mediaTypes.banner.name is not set', () => {
Expand All @@ -503,8 +501,8 @@ describe('prebid/installPrebidNpm', () => {
];
pbjs.requestBids({ adUnits } as any);

expect(adUnits[0].bids).toHaveLength(1);
expect(adUnits[0].bids[0].params.zone).toBeUndefined();
const tsBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any;
expect(tsBid.params.zone).toBeUndefined();
});

it('omits zone when ad unit has no mediaTypes', () => {
Expand All @@ -513,8 +511,8 @@ describe('prebid/installPrebidNpm', () => {
const adUnits = [{ bids: [{ bidder: 'rubicon', params: {} }] }];
pbjs.requestBids({ adUnits } as any);

expect(adUnits[0].bids).toHaveLength(1);
expect(adUnits[0].bids[0].params.zone).toBeUndefined();
const tsBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any;
expect(tsBid.params.zone).toBeUndefined();
});

it('clears stale zone when existing trustedServer bid is reused', () => {
Expand Down Expand Up @@ -551,10 +549,10 @@ describe('prebid/installPrebidNpm', () => {
mockPbjs.adUnits = [{ bids: [{ bidder: 'openx', params: {} }] }] as any[];
pbjs.requestBids({} as any);

// Original openx bid should be removed, only trustedServer remains
const unit = mockPbjs.adUnits[0] as any;
expect(unit.bids).toHaveLength(1);
expect(unit.bids[0].bidder).toBe('trustedServer');
const hasTsBidder = (mockPbjs.adUnits[0] as any).bids.some(
(b: any) => b.bidder === 'trustedServer'
);
expect(hasTsBidder).toBe(true);
});
});
});
Expand Down Expand Up @@ -613,7 +611,7 @@ describe('prebid/client-side bidders', () => {
delete (window as any).__tsjs_prebid;
});

it('excludes client-side bidders from trustedServer bidderParams and removes server-side bids', () => {
it('excludes client-side bidders from trustedServer bidderParams', () => {
(window as any).__tsjs_prebid = { clientSideBidders: ['rubicon'] };

const pbjs = installPrebidNpm();
Expand All @@ -629,18 +627,13 @@ describe('prebid/client-side bidders', () => {
];
pbjs.requestBids({ adUnits } as any);

// Only rubicon (client-side) and trustedServer should remain
expect(adUnits[0].bids).toHaveLength(2);
const tsBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any;
expect(tsBid).toBeDefined();
// rubicon should NOT be in bidderParams — it runs client-side
expect(tsBid.params.bidderParams).toEqual({
appnexus: { placementId: 123 },
kargo: { placementId: 'k1' },
});
// appnexus and kargo should be removed (absorbed into trustedServer)
expect(adUnits[0].bids.find((b: any) => b.bidder === 'appnexus')).toBeUndefined();
expect(adUnits[0].bids.find((b: any) => b.bidder === 'kargo')).toBeUndefined();
});

it('preserves client-side bidder bids as standalone entries', () => {
Expand Down Expand Up @@ -680,8 +673,6 @@ describe('prebid/client-side bidders', () => {
];
pbjs.requestBids({ adUnits } as any);

// 3 bids: rubicon (client-side), openx (client-side), trustedServer
expect(adUnits[0].bids).toHaveLength(3);
const tsBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any;
// Only appnexus should be in bidderParams
expect(tsBid.params.bidderParams).toEqual({
Expand All @@ -691,9 +682,6 @@ describe('prebid/client-side bidders', () => {
// Both client-side bidders should remain
expect(adUnits[0].bids.find((b: any) => b.bidder === 'rubicon')).toBeDefined();
expect(adUnits[0].bids.find((b: any) => b.bidder === 'openx')).toBeDefined();

// Server-side bidder should be removed
expect(adUnits[0].bids.find((b: any) => b.bidder === 'appnexus')).toBeUndefined();
});

it('behaves normally when no client-side bidders are configured', () => {
Expand All @@ -710,10 +698,7 @@ describe('prebid/client-side bidders', () => {
];
pbjs.requestBids({ adUnits } as any);

// All original bidders should be removed, only trustedServer remains
expect(adUnits[0].bids).toHaveLength(1);
const tsBid = adUnits[0].bids[0] as any;
expect(tsBid.bidder).toBe('trustedServer');
const tsBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any;
expect(tsBid.params.bidderParams).toEqual({
appnexus: { placementId: 123 },
rubicon: { accountId: 'abc' },
Expand All @@ -735,10 +720,7 @@ describe('prebid/client-side bidders', () => {
];
pbjs.requestBids({ adUnits } as any);

// All original bidders should be removed, only trustedServer remains
expect(adUnits[0].bids).toHaveLength(1);
const tsBid = adUnits[0].bids[0] as any;
expect(tsBid.bidder).toBe('trustedServer');
const tsBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any;
expect(tsBid.params.bidderParams).toEqual({
appnexus: { placementId: 123 },
rubicon: { accountId: 'abc' },
Expand All @@ -760,8 +742,7 @@ describe('prebid/client-side bidders', () => {
];
pbjs.requestBids({ adUnits } as any);

// All 3 should be present: rubicon, appnexus (both client-side), and trustedServer
expect(adUnits[0].bids).toHaveLength(3);
// trustedServer should still be present (even with empty bidderParams)
const tsBid = adUnits[0].bids.find((b: any) => b.bidder === 'trustedServer') as any;
expect(tsBid).toBeDefined();
expect(tsBid.params.bidderParams).toEqual({});
Expand Down
9 changes: 5 additions & 4 deletions crates/trusted-server-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,11 @@ Behavior is covered by an extensive test suite in `crates/trusted-server-core/sr

## Edge Cookie (EC) Identifier Propagation

- `edge_cookie.rs` generates an edge cookie identifier per user request and exposes helpers:
- `generate_ec_id` — creates a fresh HMAC-based ID using the client IP address and appends a short random suffix (format: `64hex.6alnum`).
- `get_ec_id` — extracts an existing ID from the `x-ts-ec` header or `ts-ec` cookie.
- `get_or_generate_ec_id` — reuses the existing ID when present, otherwise creates one.
- The `ec/` module owns the EC identity subsystem:
- `ec/generation.rs` — creates HMAC-based IDs using the client IP and publisher passphrase (format: `64hex.6alnum`).
- `ec/mod.rs` — `EcContext` struct with two-phase lifecycle (`read_from_request` + `generate_if_needed`), `get_ec_id` helper.
- `ec/consent.rs` — EC-specific consent gating wrapper.
- `ec/cookies.rs` — `Set-Cookie` header creation and expiration helpers.
- `publisher.rs::handle_publisher_request` stamps proxied origin responses with `x-ts-ec`, and (when absent) issues the `ts-ec` cookie so the browser keeps the identifier on subsequent requests.
- `proxy.rs::handle_first_party_proxy` replays the identifier to third-party creative origins by appending `ts-ec=<value>` to the reconstructed target URL, follows redirects (301/302/303/307/308) up to four hops, and keeps downstream fetches linked to the same user scope.
- `proxy.rs::handle_first_party_click` adds `ts-ec=<value>` to outbound click redirect URLs so analytics endpoints can associate clicks with impressions without third-party cookies.
46 changes: 24 additions & 22 deletions crates/trusted-server-core/src/auction/endpoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,8 @@ use error_stack::{Report, ResultExt};
use fastly::{Request, Response};

use crate::auction::formats::AdRequest;
use crate::consent;
use crate::cookies::handle_request_cookies;
use crate::edge_cookie::get_or_generate_ec_id;
use crate::ec::EcContext;
use crate::error::TrustedServerError;
use crate::geo::GeoInfo;
use crate::settings::Settings;

use super::formats::{convert_to_openrtb_response, convert_tsjs_to_auction_request};
Expand All @@ -33,6 +30,18 @@ pub async fn handle_auction(
orchestrator: &AuctionOrchestrator,
mut req: Request,
) -> Result<Response, Report<TrustedServerError>> {
// Read EC state before consuming the request body.
let mut ec_context = EcContext::read_from_request(settings, &req).change_context(
TrustedServerError::Auction {
message: "Failed to read EC context".to_string(),
},
)?;

// Auction is an organic handler — generate EC if needed.
if let Err(err) = ec_context.generate_if_needed(settings) {
log::warn!("EC generation failed for auction: {err:?}");
}

// Parse request body
let body: AdRequest = serde_json::from_slice(&req.take_body_bytes()).change_context(
TrustedServerError::Auction {
Expand All @@ -45,27 +54,20 @@ pub async fn handle_auction(
body.ad_units.len()
);

// Generate EC ID early so the consent pipeline can use it for
// KV Store fallback/write operations.
let ec_id =
get_or_generate_ec_id(settings, &req).change_context(TrustedServerError::Auction {
message: "Failed to generate EC ID".to_string(),
})?;

// Extract consent from request cookies, headers, and geo.
let cookie_jar = handle_request_cookies(&req)?;
let geo = GeoInfo::from_request(&req);
let consent_context = consent::build_consent_context(&consent::ConsentPipelineInput {
jar: cookie_jar.as_ref(),
req: &req,
config: &settings.consent,
geo: geo.as_ref(),
ec_id: Some(ec_id.as_str()),
});
// Only forward the EC ID to auction partners when consent allows it.
// A returning user may still have a ts-ec cookie but have since
// withdrawn consent — forwarding that revoked ID to bidders would
// defeat the consent gating.
let ec_id = if ec_context.ec_allowed() {
ec_context.ec_value().unwrap_or("")
} else {
""
};
let consent_context = ec_context.consent().clone();

// Convert tsjs request format to auction request
let auction_request =
convert_tsjs_to_auction_request(&body, settings, &req, consent_context, &ec_id)?;
convert_tsjs_to_auction_request(&body, settings, &req, consent_context, ec_id)?;

// Create auction context
let context = AuctionContext {
Expand Down
8 changes: 1 addition & 7 deletions crates/trusted-server-core/src/auction/formats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,8 @@ use uuid::Uuid;

use crate::auction::context::ContextValue;
use crate::consent::ConsentContext;
use crate::constants::{HEADER_X_TS_EC, HEADER_X_TS_EC_FRESH};
use crate::constants::HEADER_X_TS_EC;
use crate::creative;
use crate::edge_cookie::generate_ec_id;
use crate::error::TrustedServerError;
use crate::geo::GeoInfo;
use crate::openrtb::{to_openrtb_i32, OpenRtbBid, OpenRtbResponse, ResponseExt, SeatBid, ToExt};
Expand Down Expand Up @@ -88,9 +87,6 @@ pub fn convert_tsjs_to_auction_request(
ec_id: &str,
) -> Result<AuctionRequest, Report<TrustedServerError>> {
let ec_id = ec_id.to_owned();
let fresh_id = generate_ec_id(settings, req).change_context(TrustedServerError::Auction {
message: "Failed to generate fresh EC ID".to_string(),
})?;

// Convert ad units to slots
let mut slots = Vec::new();
Expand Down Expand Up @@ -184,7 +180,6 @@ pub fn convert_tsjs_to_auction_request(
},
user: UserInfo {
id: ec_id,
fresh_id,
consent: Some(consent),
},
device,
Expand Down Expand Up @@ -312,6 +307,5 @@ pub fn convert_to_openrtb_response(
Ok(Response::from_status(StatusCode::OK)
.with_header(header::CONTENT_TYPE, "application/json")
.with_header(HEADER_X_TS_EC, &auction_request.user.id)
.with_header(HEADER_X_TS_EC_FRESH, &auction_request.user.fresh_id)
.with_body(body_bytes))
}
Loading
Loading