diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ee0eec56..9904eae3 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 901c9ce1..00c2179a 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 9c2d7549..17729848 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 e11f27bb..927e5aac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4252,6 +4252,7 @@ dependencies = [ "tempfile", "tokio", "toml", + "toml_edit", "trusted-server-core", "url", "validator", diff --git a/Cargo.toml b/Cargo.toml index 1607ccb7..51feb407 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.0" +toml_edit = "0.23.10" trusted-server-core = { path = "crates/trusted-server-core" } url = "2.5.8" urlencoding = "2.1" diff --git a/crates/js/lib/build-all.mjs b/crates/js/lib/build-all.mjs index 61892df6..ce465a72 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/build-prebid-external.mjs b/crates/js/lib/build-prebid-external.mjs new file mode 100644 index 00000000..8cb7efd0 --- /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 9beca211..2ffed57e 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/js/lib/src/integrations/prebid/_adapters.generated.ts b/crates/js/lib/src/integrations/prebid/_adapters.generated.ts index e73f6aea..b48904b9 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 24e80285..8911dbd2 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 6038f42a..156efbbe 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 56b202cb..a1371e53 100644 --- a/crates/trusted-server-adapter-fastly/src/route_tests.rs +++ b/crates/trusted-server-adapter-fastly/src/route_tests.rs @@ -400,7 +400,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 { diff --git a/crates/trusted-server-cli/Cargo.toml b/crates/trusted-server-cli/Cargo.toml index 98c9a55e..16f7eec3 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 670fe835..9417421c 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 26c1c37d..147f77ff 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 00000000..f5aa088c --- /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 8582b671..3cbb014f 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/crates/trusted-server-core/README.md b/crates/trusted-server-core/README.md index 6f323456..3049a111 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 dd39ad89..4d7780fe 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"; @@ -107,6 +119,18 @@ pub struct PrebidIntegrationConfig { deserialize_with = "crate::settings::vec_from_seq_or_map" )] pub script_patterns: Vec, + /// 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 +290,163 @@ 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, + } + } +} + +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", + )); + } + + 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", + )); + } + } + + Ok(()) +} + +fn validate_external_bundle_sri(value: &str) -> Result<(), ValidationError> { + parse_external_bundle_sri(value) +} + +fn validate_external_bundle_config( + config: &PrebidIntegrationConfig, + allowed_domains: &[String], +) -> Result<(), Report> { + let url = config.external_bundle_url.as_deref().ok_or_else(|| { + Report::new(TrustedServerError::Configuration { + message: "integrations.prebid.external_bundle_url is required when prebid is enabled" + .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" + ), + })); + } + + Ok(()) +} + pub struct PrebidIntegration { config: PrebidIntegrationConfig, engine: Arc, @@ -362,16 +543,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 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 is_managed_external(&self) -> bool { + self.config.external_bundle_url.is_some() + } + + 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(_)) => 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 prebid is enabled" + .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 +733,8 @@ fn build( return Ok(None); }; + 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 // `client_side_bidders` (browser-side), not both. @@ -417,7 +769,7 @@ pub fn register( .with_proxy(integration.clone()) .with_attribute_rewriter(integration.clone()) .with_head_injector(integration) - .with_deferred_js() + .without_js() .build(), )) } @@ -431,6 +783,8 @@ impl IntegrationProxy for PrebidIntegration { fn routes(&self) -> Vec { let mut routes = vec![]; + 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 +800,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 +879,13 @@ 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};"# - )] + )]; + + inserts.push(self.external_bundle_script_tag()); + + inserts } } @@ -1706,6 +2067,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; @@ -1726,6 +2088,11 @@ mod tests { test_mode: false, debug_query_params: None, script_patterns: default_script_patterns(), + 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(), bid_param_zone_overrides: HashMap::default(), bid_param_overrides: HashMap::default(), @@ -1734,6 +2101,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(), @@ -1926,6 +2327,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": [], @@ -1976,6 +2378,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"], @@ -2010,8 +2413,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" ); } @@ -2058,6 +2465,204 @@ server_url = "https://prebid.example" .contains(&"/prebid.min.js".to_string())); } + #[test] + fn external_bundle_config_parses_with_optional_hash_metadata() { + let config = parse_prebid_toml( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +external_bundle_url = "https://assets.example/prebid/trusted-prebid.js" +"#, + ); + + 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 external_bundle_config_rejects_malformed_hash_metadata() { + let err = parse_prebid_toml_result( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +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 external_bundle_config_rejects_non_https_bundle_url() { + let err = parse_prebid_toml_result( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +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 external_bundle_config_rejects_invalid_sri_base64() { + let err = parse_prebid_toml_result( + r#" +[integrations.prebid] +enabled = true +server_url = "https://prebid.example" +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 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" +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 external_bundle_registration_allows_sha256_without_sri() { + let mut settings = make_settings(); + settings + .integrations + .insert_config( + "prebid", + &json!({ + "enabled": true, + "server_url": "https://prebid.example/openrtb2/auction", + "external_bundle_url": "https://assets.example/prebid/trusted-prebid.js", + "external_bundle_sha256": "0".repeat(64) + }), + ) + .expect("should update prebid config"); + + let registry = IntegrationRegistry::new(&settings) + .expect("should create registry with valid SHA-256 and no SRI"); + + assert!( + registry.has_route(&Method::GET, PREBID_BUNDLE_ROUTE), + "should register external bundle route" + ); + } + + #[test] + fn external_bundle_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", + "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 external bundle route" + ); + } + + #[test] + fn external_bundle_registration_requires_bundle_url() { + let mut settings = make_settings(); + settings + .integrations + .insert_config( + "prebid", + &json!({ + "enabled": true, + "server_url": "https://prebid.example/openrtb2/auction" + }), + ) + .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 external_bundle_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", + "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()); @@ -2093,6 +2698,253 @@ 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.external_bundle_url = + Some("https://assets.example/prebid/trusted-prebid.js".to_string()); + config.external_bundle_sha256 = Some(sha256.clone()); + 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.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.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.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.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()); @@ -2114,6 +2966,12 @@ 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 && r.method == Method::GET), + "should register the bundle route" + ); } #[test] @@ -2128,7 +2986,7 @@ server_url = "https://prebid.example" }; 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!( @@ -2179,6 +3037,72 @@ server_url = "https://prebid.example" ); } + #[test] + fn head_injector_emits_external_bundle_script_with_hash_and_integrity() { + let sha256 = "a".repeat(64); + let mut config = base_config(); + 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_external_bundle_script_without_hash_query_when_unhashed() { + let mut config = base_config(); + 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(); @@ -3346,8 +4270,17 @@ server_url = "https://prebid.example" 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 78ff7fa2..2e5239d1 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -392,6 +392,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>, @@ -416,6 +417,7 @@ impl IntegrationRegistrationBuilder { registration: IntegrationRegistration { integration_id, js_deferred: false, + js_disabled: false, proxies: Vec::new(), attribute_rewriters: Vec::new(), script_rewriters: Vec::new(), @@ -469,6 +471,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 @@ -490,6 +500,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>, @@ -507,11 +518,12 @@ 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(), - deferred_js_ids: Vec::new(), } } } @@ -634,7 +646,9 @@ impl IntegrationRegistry { inner .head_injectors .extend(registration.head_injectors.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); } } @@ -829,7 +843,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); } } @@ -882,6 +899,7 @@ impl IntegrationRegistry { html_post_processors: Vec::new(), head_injectors: Vec::new(), deferred_js_ids: Vec::new(), + disabled_js_ids: Vec::new(), }), } } @@ -908,6 +926,7 @@ impl IntegrationRegistry { html_post_processors: Vec::new(), head_injectors, deferred_js_ids: Vec::new(), + disabled_js_ids: Vec::new(), }), } } @@ -970,6 +989,7 @@ impl IntegrationRegistry { html_post_processors: Vec::new(), head_injectors: Vec::new(), deferred_js_ids: Vec::new(), + disabled_js_ids: Vec::new(), }), } } @@ -1557,7 +1577,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 @@ -1567,6 +1587,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 @@ -1582,8 +1603,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"), @@ -1598,8 +1619,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" ); } @@ -1612,7 +1633,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"); @@ -1626,6 +1648,41 @@ mod tests { ); } + #[test] + fn js_module_ids_exclude_prebid_when_external_bundle_is_configured() { + 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", + "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"), + "external bundle mode should not include prebid in embedded TSJS modules" + ); + assert!( + !registry.js_module_ids_immediate().contains(&"prebid"), + "external bundle mode should not include prebid in immediate TSJS modules" + ); + assert!( + !registry.js_module_ids_deferred().contains(&"prebid"), + "external bundle mode should not include prebid in deferred TSJS modules" + ); + assert!( + registry.has_route(&Method::GET, "/integrations/prebid/bundle.js"), + "external bundle 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(); @@ -1637,6 +1694,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/proxy.rs b/crates/trusted-server-core/src/proxy.rs index 77a11c23..abbfe5fa 100644 --- a/crates/trusted-server-core/src/proxy.rs +++ b/crates/trusted-server-core/src/proxy.rs @@ -315,6 +315,8 @@ pub struct ProxyRequestConfig<'a> { /// should leave this empty; first-party handlers should pass /// `&settings.proxy.allowed_domains` to enforce the publisher allowlist. 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> { @@ -330,6 +332,7 @@ impl<'a> ProxyRequestConfig<'a> { copy_request_headers: true, stream_passthrough: false, allowed_domains: &[], + require_https: false, } } @@ -360,6 +363,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`. @@ -661,6 +685,7 @@ struct ProxyRequestHeaders<'a> { /// Empty slice means open mode (all hosts allowed). Populated by first-party /// handlers; integration proxies leave it empty. allowed_domains: &'a [String], + require_https: bool, } /// Proxy a request to a clear target URL while reusing creative rewrite logic. @@ -688,6 +713,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(|_| { @@ -711,6 +737,7 @@ pub async fn proxy_request( copy_request_headers, services, allowed_domains, + require_https, }, stream_passthrough, ) @@ -1185,7 +1212,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(); @@ -1226,6 +1253,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() { @@ -1351,8 +1384,20 @@ 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, ¤t_url, beresp, 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, @@ -1427,6 +1472,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, ) @@ -2838,6 +2884,7 @@ mod tests { copy_request_headers: false, stream_passthrough: false, allowed_domains: &[], + require_https: false, }, &services, ) @@ -2888,6 +2935,7 @@ mod tests { copy_request_headers: true, stream_passthrough: false, allowed_domains: &[], + require_https: false, }, &services, ) @@ -2951,6 +2999,7 @@ mod tests { copy_request_headers: false, stream_passthrough: false, allowed_domains: &[], + require_https: false, }, &services, ) @@ -4218,6 +4267,38 @@ mod tests { // `redirect_is_permitted` and `ip_literal_blocked_by_domain_allowlist` // cover the blocking logic used at every hop. + #[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" + ); + } + #[tokio::test] async fn proxy_initial_target_blocked_by_allowlist() { use crate::http_util::compute_encrypted_sha256_token; diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 710eaa70..22355342 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -189,7 +189,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 @@ -1192,14 +1192,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" ); } @@ -1221,14 +1221,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() { + fn tsjs_dynamic_does_not_serve_embedded_prebid() { let settings = create_test_settings(); let registry = IntegrationRegistry::new(&settings).expect("should create integration registry"); @@ -1240,8 +1240,8 @@ 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" + StatusCode::NOT_FOUND, + "should not serve embedded prebid module" ); } @@ -1254,7 +1254,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 3b91886c..d343ac96 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/crates/trusted-server-core/src/tsjs.rs b/crates/trusted-server-core/src/tsjs.rs index 7db6dc5c..2f680a61 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" ); } @@ -169,17 +169,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=", diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 6bf0f681..0920c937 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/creative-processing.md b/docs/guide/creative-processing.md index 7a8977a9..cad9a5cf 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 79576b8b..e3b6a34c 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 ` +``` + +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 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 `https://` and the redirect + target host is permitted by `proxy.allowed_domains` +- 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 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 + PBS-->>TS: OpenRTB response + TS-->>B: auction response +``` + +## HTML Injection Behavior + +When Prebid is enabled, 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`, 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. + +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, Trusted Server always owns Prebid loading through the generated +external bundle. 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 asset URL. When the +manifest includes hash values, config should also reference those values to +enable content-addressed delivery, immutable caching, and browser SRI when +configured. + +## 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 `external_bundle_url`, `external_bundle_sha256`, and + `external_bundle_sri` fields to `PrebidIntegrationConfig`. +- 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 a Prebid loading path. +- Existing deferred-module serving can remain for other integrations. + +## Response Headers + +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, 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, validators may be omitted. Phase 1 preserves streaming passthrough, +so Trusted Server should not buffer the bundle solely to compute an `ETag`. + +## 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 + +When configured, browser SRI validates the first-party proxied response. SRI is +recommended with `external_bundle_sha256`, but it is not required; deployments may +use the hash metadata only for versioned first-party URLs, cache policy, and +manifest auditing. + +Phase 1 does not perform mandatory edge-side SHA-256 byte validation. Doing so +would require buffering the proxied bundle and would break the streaming +passthrough behavior for this route. 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 + +1. Add required external bundle URL config and optional hash/SRI metadata. +2. Add first-party bundle proxy route and injection. +3. Add external bundle generation tooling and manifest output. +4. Remove Prebid from the embedded TSJS build and never register it as deferred JS. +5. Update docs and examples to point publishers at the generated external bundle. + +## Test Plan + +### Rust Tests + +- Config validation accepts valid external bundle settings. +- Config validation rejects missing required external bundle settings and malformed optional hash or SRI metadata. +- Registry does not include `prebid` in embedded or deferred JS IDs. +- 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 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 + +- External generated bundle registers the `trustedServer` adapter. +- External generated bundle shims `requestBids()` as the previous embedded bundle + did. +- 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`. +- Browser loads `/integrations/prebid/bundle.js?v=` from first-party + 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. + +## Final Phase 1 Decisions + +- Edge-side SHA-256 byte validation is not mandatory in Phase 1 because the route + preserves streaming passthrough. +- Redirects are allowed only when every hop remains `https://` and each redirect + target host is permitted by `proxy.allowed_domains`. +- The injected script tag omits `crossorigin` because the browser-visible bundle + URL is same-origin. 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 00000000..ee73e9b2 --- /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 00000000..eef9e2f7 --- /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 0e8226ef..bcc450ba 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"] diff --git a/trusted-server.toml b/trusted-server.toml new file mode 100644 index 00000000..ef3bd39f --- /dev/null +++ b/trusted-server.toml @@ -0,0 +1,261 @@ +[[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"