Skip to content

Keep Lockr API routing first-party without polling - #1406

Open
ChristianPavilonis wants to merge 6 commits into
mainfrom
fix/1247-lockr-shim
Open

ChristianPavilonis wants to merge 6 commits into
mainfrom
fix/1247-lockr-shim

Conversation

@ChristianPavilonis

Copy link
Copy Markdown
Collaborator

Summary

  • Remove redundant Lockr SDK polling, private-global access, host mutation, and timeout warnings. The dedicated trust-server SDK already routes API requests through the first-party proxy.
  • Preserve script interception and keep exact root-relative Lockr API requests on the page origin at XMLHttpRequest.open, including pages with an external <base href>.
  • Add initializer/API-routing regression coverage and document the SDK routing contract.

The issue's diagnosis needed correcting: a top-level lexical identityLockr is accessible to another classic script without being a window property. The old shim does run for the current SDK. Public window.lockr has methods but no API host, so renaming the polling target would not preserve the old behavior.

Changes

File Change
crates/trusted-server-js/lib/src/integrations/lockr/index.ts Install the script and API guards without polling or mutating SDK internals
crates/trusted-server-js/lib/src/integrations/lockr/api_guard.ts Anchor only exact root-relative Lockr API paths to the page origin; preserve unrelated XHR calls and arguments
crates/trusted-server-js/lib/test/integrations/lockr/index.test.ts Cover initializer lifecycle, absent/late SDKs, untouched public API, and external-base routing
crates/trusted-server-js/lib/test/integrations/lockr/api_guard.test.ts Cover exact API path boundaries, argument forwarding, native errors, and guard lifecycle
docs/guide/configuration.md Clarify native SDK routing and compatibility scope

The API guard addresses a regression caught during local browser verification of the initial deletion: under an external document base, the SDK's relative API host resolves to that base origin. The old shim's absolute host prevented that. The guard preserves the destination without restoring polling or depending on an SDK private global.

SDK payload serving, Rust proxy behavior, fetch, and SDK URL matching are unchanged. Issue #1199 remains separate. Custom SDK builds that relied on mutation of their internal host still need an explicit routing contract; this does not promise generic legacy-SDK adaptation.

Closes

Closes #1247

Test plan

  • Focused Lockr tests: 67 passed, including initializer and API-origin guard regressions
  • Full JS suite: 52 files, 1,240 tests passed
  • Focused strict TypeScript check of api_guard.ts
  • Full JS ESLint and Prettier checks
  • JS build: all 13 bundles built
  • Docs formatting and cargo fmt --all -- --check
  • cargo build --locked -p trusted-server-adapter-axum -j 8
  • Local ts dev proxy plus installed-Chrome harness acceptance, initial load and reload
  • Real-SDK external-base regression probe: both relative-host and absolute-host settings requests stay on the publisher origin, with outbound requests aborted before transmission
  • git diff --cached --check and ts dev lint domains --staged

The final local browser run passed in 80.148 seconds with zero artifact capture errors. It verified the exact newly built Lockr module in both served bundles, SDK/settings/page-view HTTP 200 on both navigations, public SDK methods and one ID5 EID source, preserved script interception, and no Lockr detection warnings. The API host itself stays relative; the new XHR guard pins request resolution to the page origin. Raw artifacts remain local and are not attached because they may contain credentials and identity values.

Remaining validation

Full adapter/Rust/parity CI gates have not all been run locally. Keep this PR in draft pending those checks and final review. Local Axum browser verification is Lockr-focused, not a reproduction of the entire production ad stack; the native environment lacks production KV/Geo behavior. The run also captured one page JavaScript error and two existing direct vendor image ORB failures. The original production timeout trigger and generic custom/legacy SDK compatibility remain unverified. No all-vendor-traffic or whole-site regression claim is intended.

Checklist

Use the dedicated SDK's native API routing instead of polling for and mutating its internal global. Preserve publisher-origin XHR routing under external base URLs with a narrowly scoped API guard, and cover initializer and transport behavior with regression tests.
@ChristianPavilonis
ChristianPavilonis marked this pull request as ready for review October 16, 2026 19:55

@dhruv8sh dhruv8sh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Removes the Lockr shim that polled for and mutated the SDK's private identityLockr global, and replaces it with a narrowly scoped XMLHttpRequest.open guard that keeps root-relative /integrations/lockr/api requests on the page origin under an external <base href>. Clean, well-tested change; no blocking issues.

Non-blocking

🤔 thinking

  • Guard covers XHR only; fetch / sendBeacon would still follow <base href> — see inline at crates/trusted-server-js/lib/src/integrations/lockr/api_guard.ts:28
  • Migration note omits the rewrite_sdk = false + legacy SDK case — see inline at docs/guide/configuration.md:1659

👍 praise

  • Tight URL matching with boundary and argument-preservation tests — see inline at crates/trusted-server-js/lib/src/integrations/lockr/api_guard.ts:24
  • Removes private-global polling and mutation — see inline at crates/trusted-server-js/lib/src/integrations/lockr/index.ts:7

Cross-cutting / body-level findings

  • 📌 Static SDK <script> tags still resolve against an external <base href> — The server-side attribute rewrite in crates/trusted-server-core/src/integrations/lockr.rs:397-405 replaces a static Lockr SDK src with root-relative /integrations/lockr/sdk, and its comment calls resolution against <base href> an accepted tradeoff. On external-base pages the SDK itself is therefore requested from the base origin, so the new API guard only helps once the SDK loads. Dynamically inserted tags are already absolute (window.location.origin + proxyPath in shared/script_guard.ts). Worth a follow-up to make the HTML rewrite absolute too, or to document the limitation, so the external-base guarantee holds end to end.
  • 📝 PR description is stale — "Remaining validation" says "Keep this PR in draft pending those checks", but the PR is not a draft and all remote CI gates (including parity and adapter tests) now pass. Consider updating that caveat.

CI Status

  • format-docs: PASS (required)
  • format-typescript: PASS (required)
  • cargo fmt: PASS (required)
  • cargo test: PASS (required)
  • vitest: PASS
  • cargo test (axum native): PASS
  • cargo test (cross-adapter parity): PASS
  • cargo check (cloudflare native + wasm32-unknown-unknown): PASS
  • cargo check/build/test (spin native + wasm32-wasip1): PASS
  • cargo test (ts CLI, native) (ubuntu-latest): PASS
  • cargo test (ts CLI, native) (macos-latest): PASS
  • integration tests: PASS
  • integration tests (Fastly EC lifecycle): PASS
  • browser integration tests: PASS
  • prepare integration artifacts: PASS
  • CodeQL: PASS
  • Analyze (rust): PASS
  • Analyze (actions): PASS
  • Analyze (python): PASS
  • Analyze (javascript-typescript): PASS
  • CLAUDE.md symlink guard: PASS

Comment on lines +3 to +28
/**
* Keep root-relative Lockr API requests on the page origin, even with an external base URL.
*/
export function installLockrApiGuard(): void {
if (uninstall || typeof window === 'undefined' || typeof window.XMLHttpRequest === 'undefined') {
return;
}

const prototype = window.XMLHttpRequest.prototype;
const originalOpen = prototype.open;
const open: typeof originalOpen = function (
this: XMLHttpRequest,
...args: [
method: string,
url: string | URL,
async?: boolean,
username?: string | null,
password?: string | null,
]
): void {
const url = args[1];
if (typeof url === 'string' && /^\/integrations\/lockr\/api(?:[/?#]|$)/.test(url)) {
args[1] = `${window.location.origin}${url}`;
}
Reflect.apply(originalOpen, this, args);
};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 thinking — The external-base fix relies on the current SDK sending every API call through XMLHttpRequest.open. If an SDK update moves any endpoint (page-view beacons are a common candidate) to fetch or navigator.sendBeacon, those requests would silently resolve against <base href> again, with no test or log to catch it. Can you confirm that all current SDK API calls (settings, page-view, generate-tokens, revoke-consent) use XHR? If so, consider recording the dependency in the doc comment:

/**
 * Keep root-relative Lockr API requests on the page origin, even with an external base URL.
 *
 * Only `XMLHttpRequest.open` is guarded: the trust-server SDK issues every API call via XHR.
 * Requests made with `fetch` or `navigator.sendBeacon` are not covered and would still
 * resolve against a document `<base href>`.
 */

Comment on lines +1657 to +1659
Trusted Server no longer rewrites the SDK API host. Custom `sdk_url` builds must
route their own API requests through the first-party proxy; builds that depended
on the removed SDK-host shim need migration.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 thinking — This note names only custom sdk_url builds. With rewrite_sdk = false, a page that loads a non-trust-server Lockr SDK (e.g. identity-lockr-v*.js) directly from Lockr keeps that SDK, since the server substitutes the configured sdk_url only when the tag is rewritten. The removed shim used to point that SDK's API host back at the proxy (at least for SDKs exposing identityLockr); now those API calls go straight to Lockr, a third party, with no warning. Consider covering that path in the migration note:

Trusted Server no longer rewrites the SDK API host. Custom `sdk_url` builds, and
pages that load a non-trust-server Lockr SDK directly with `rewrite_sdk = false`,
must route their own API requests through the first-party proxy; builds that
depended on the removed SDK-host shim need migration.

]
): void {
const url = args[1];
if (typeof url === 'string' && /^\/integrations\/lockr\/api(?:[/?#]|$)/.test(url)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 praise — The anchored match ^\/integrations\/lockr\/api(?:[/?#]|$) correctly rejects /apiary, /api-v2, relative, protocol-relative, and absolute URLs, and the tests cover each of those boundaries. Reflect.apply keeps the receiver, sync flag, credentials, and native argument validation intact, and resetApiGuardState won't clobber a later interceptor. Pinning to location.origin also stops an injected <base href> from redirecting identity API traffic.

Comment on lines +1 to 7
import { installLockrApiGuard } from './api_guard';
import { installLockrGuard } from './script_guard';

// Type definition for Lockr global
declare const identityLockr: IdentityLockr | undefined;

interface IdentityLockr {
host: string;
app_id: string;
expiryDateKeys: string[];
firstPartyCookies: string[];
canRefreshToken: boolean;
macroDetectionEnabled: boolean;
iluiMacroDetection: boolean;
gdprApplies: boolean;
consentString: string;
gppString: string;
ccpaString: string;
isUTMTagsLoaded: boolean;
isFirstPartyCookiesLoaded: boolean;
allowedUTMTags: string[];
lockrTrackingID: string;
panoramaClientId: string;
writeToDeviceConsentEUID: boolean;
id5JSEnabled: boolean;
firstIDPassHEM: boolean;
panoramaPassHEM: boolean;
firstIDEnabled: boolean;
panoramaEnabled: boolean;
isAdelphicEnabled: boolean;
os: string;
browser: string;
country: string;
city: string;
latitude: string;
longitude: string;
ip: string;
hashedUserAgent: string;
tokenMappings: Record<string, string>;
tokenSourceMappings: Record<string, string>;
identitProvidersType: Record<string, string>;
identityIdEncryptionSalt: string;
}

/**
* Install the Lockr shim to rewrite API endpoints to first-party domain.
* This function is called after the Lockr SDK has loaded and initialized.
*/
function installLockrShim() {
log.info('Installing Lockr shim - rewriting API host to first-party domain');

if (typeof identityLockr === 'undefined' || !identityLockr) {
log.warn('Lockr shim: identityLockr global not found');
return;
}

const host = window.location.host;
const protocol = window.location.protocol === 'https:' ? 'https' : 'http';

// Store original host for debugging
const originalHost = identityLockr.host;

// Rewrite to first-party domain
// The Lockr SDK will now make all API calls through our proxy
identityLockr.host = `${protocol}://${host}/integrations/lockr/api`;

log.info('Lockr shim installed', {
originalHost,
newHost: identityLockr.host,
appId: identityLockr.app_id,
});
}

/**
* Wait for Lockr SDK to be available before installing shim.
* Polls for SDK availability with a maximum number of attempts.
*
* @param callback - Function to call when SDK is available
* @param maxAttempts - Maximum number of polling attempts (default: 50)
*/
function waitForLockrSDK(callback: () => void, maxAttempts = 50) {
let attempts = 0;

const check = () => {
attempts++;

// Check if identityLockr global exists and is initialized with host
if (typeof identityLockr !== 'undefined' && identityLockr && identityLockr.host) {
log.info('Lockr SDK detected, installing shim');
callback();
} else if (attempts < maxAttempts) {
// Check again in 50ms
setTimeout(check, 50);
} else {
log.warn('Lockr SDK not detected after', maxAttempts * 50, 'ms');
}
};

check();
}

if (typeof window !== 'undefined') {
installLockrApiGuard();
installLockrGuard();

waitForLockrSDK(() => installLockrShim());
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 praise — Nice cleanup: this drops ~100 lines that depended on an undocumented SDK internal (a 50×50 ms polling loop and spurious timeout warnings) in favour of side-effect-free guard installation. The new index.test.ts pins that down (no timers, no warnings, untouched identityLockr.host and public window.lockr).

@prk-Jr prk-Jr left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

The change removes Lockr SDK polling and private-host mutation while preserving first-party API routing for root-relative XHR requests, including pages with an external base URL. No actionable findings.

Validation: all 67 focused Lockr tests passed under Node 24.12.0; checked routing boundaries, argument and receiver forwarding, initializer lifecycle, and existing script interception.

CI Status

  • browser integration tests: PASS
  • integration tests (Fastly EC lifecycle): PASS
  • integration tests: PASS
  • cargo test (axum native): PASS
  • cargo test (cross-adapter parity): PASS
  • vitest: PASS
  • cargo check (cloudflare native + wasm32-unknown-unknown): PASS
  • format-typescript: PASS (required)
  • cargo test (ts CLI, native) (ubuntu-latest): PASS
  • cargo test: PASS (required)
  • cargo fmt: PASS (required)
  • cargo check/build/test (spin native + wasm32-wasip1): PASS
  • format-docs: PASS (required)
  • docs-proposal scripts: PASS
  • CodeQL: PASS
  • prepare integration artifacts: PASS
  • Analyze (javascript-typescript): PASS
  • CLAUDE.md symlink guard: PASS
  • Analyze (actions): PASS
  • Analyze (rust): PASS
  • Analyze (javascript-typescript): PASS
  • cargo test (ts CLI, native) (macos-latest): PASS
  • Analyze (python): PASS

@aram356 aram356 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Removes the Lockr SDK polling and private-host mutation, and adds an XMLHttpRequest.open guard that keeps root-relative /integrations/lockr/api requests on the page origin under an external <base href>. The guard is correct for the SDK as served today and works in a real browser. Requesting changes for a missing CHANGELOG entry, a receiver assertion that can't fail, and three follow-through items below.

1 of the inline comments below carries a one-click GitHub suggestion. Use Commit suggestion to apply it as a commit on the PR branch. The remaining findings are in the body because they touch files outside the diff.

Blocking

🔧 wrench

  • Receiver check is structural, so it passes for any XHR — see inline at crates/trusted-server-js/lib/test/integrations/lockr/api_guard.test.ts:82

Cross-cutting / body-level findings

  • 🔧 CHANGELOG entry missing — This PR changes browser behavior on every Lockr-enabled page (a global XMLHttpRequest.prototype.open wrapper) and removes the SDK-host rewrite that the new docs/guide/configuration.md paragraph says some setups "need migration" from. Operators read CHANGELOG.md on upgrade, not the configuration reference, and recent user-facing PRs (#1238, #1223, #1221) each added an [Unreleased] entry. CHANGELOG.md isn't in the diff, so this can't be a one-click suggestion. Proposed entry under ## [Unreleased] / ### Changed:

    - The Lockr browser module no longer polls for the SDK's internal `identityLockr` global or rewrites its API host, which also removes the "Lockr SDK not detected" timeout warning (#1247). It now wraps `XMLHttpRequest.prototype.open` so root-relative `/integrations/lockr/api` requests resolve against the page origin, keeping Lockr API traffic first-party on pages with an external `<base href>`. Setups that relied on the removed host rewrite, such as a custom `sdk_url` build or a non-trust-server Lockr SDK loaded directly with `rewrite_sdk = false`, must now route their API requests through `/integrations/lockr/api` themselves.
  • 🔧 Record the SDK's XHR-only dependency in api_guard.ts — On the open question in dhruv8sh's api_guard.ts:28 thread: I checked the trust-server SDK served at the default sdk_url (identity-lockr-trust-server.js, 118,542 bytes, fetched 2026-10-22). It declares const identityLockr = {host: …} at script top level, which confirms the PR description's correction of #1247. All 9 reads of identityLockr.host are xhr.open(method, host + path), and the SDK's string table has no fetch or sendBeacon fragments, so XHR-only coverage handles every API call the SDK makes today. In Chrome, with the built tsjs-lockr.js loaded ahead of <base href="https://external-base.example/assets/">, a root-relative settings POST stayed on the page origin (200); without the bundle it went to external-base.example. The dependency is real but invisible in the code: please add the doc comment proposed in that thread, so an SDK move to fetch or sendBeacon is a documented gap rather than a silent regression.

  • 🔧 No committed browser coverage for the external-base path — The jsdom tests assert the arguments forwarded to open, not where the request actually goes, and the external-base proof in the description comes from a local harness that isn't in the repo. The browser integration suite has no Lockr scenario. A browser test that loads the Lockr bundle ahead of an external <base href> and fires a root-relative XHR from a stub SDK would pin the behavior; the probe above (two static pages and a request log) is small enough to adapt. If that's too much for this PR, file it as a follow-up and link it here.

  • 🔧 Linked Lockr guide documents config that doesn't exist — The new paragraph ends with "See [lockr]", but docs/guide/integrations/lockr.md documents organization_id and project_id fields that LockrConfig doesn't have, shows enabled defaulting to false (the code defaults to true), and never mentions app_id or sdk_url. Rewriting it is out of scope here, but there's no open issue for it; please file a follow-up and link it from this PR.

CI Status

  • browser integration tests: PASS
  • integration tests (Fastly EC lifecycle): PASS
  • integration tests: PASS
  • CodeQL: PASS
  • Analyze (actions): PASS
  • Analyze (rust): PASS
  • cargo check (cloudflare native + wasm32-unknown-unknown): PASS
  • Analyze (javascript-typescript): PASS
  • cargo test (cross-adapter parity): PASS
  • cargo check/build/test (spin native + wasm32-wasip1): PASS
  • vitest: PASS
  • cargo test (ts CLI, native) (ubuntu-latest): PASS
  • prepare integration artifacts: PASS
  • cargo test: PASS (required)
  • cargo test (ts CLI, native) (macos-latest): PASS
  • cargo test (axum native): PASS
  • format-docs: PASS (required)
  • docs-proposal scripts: PASS
  • format-typescript: PASS (required)
  • Analyze (javascript-typescript): PASS
  • cargo fmt: PASS (required)
  • Analyze (python): PASS
  • CLAUDE.md symlink guard: PASS

'user',
'password'
);
expect(open.mock.contexts).toEqual([request]);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench — Receiver check is structural, so it passes for any XHR. toEqual compares structure, and two jsdom XMLHttpRequest instances are structurally equal. A mutant that forwards to a fresh instance (Reflect.apply(originalOpen, new XMLHttpRequest(), args)) passes this test on the PR head. An identity check fails that mutant and passes as-is (Lockr tests 67/67, Prettier and ESLint clean).

Suggested change
expect(open.mock.contexts).toEqual([request]);
expect(open.mock.contexts[0]).toBe(request);

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Poll for window.lockr instead of window.identityLockr in tsjs

4 participants