Implement Edge Cookie (EC) identity system#582
Implement Edge Cookie (EC) identity system#582ChristianPavilonis wants to merge 11 commits intofeature/ssc-updatefrom
Conversation
…igration - Add ec/ module with EcContext lifecycle, generation, cookies, and consent - Compute cookie domain from publisher.domain, move EC cookie helpers - Fix auction consent gating, restore cookie_domain for non-EC cookies - Add integration proxy revocation, refactor EC parsing, clean up ec_hash - Remove fresh_id and ec_fresh per EC spec §12.1 - Migrate [edge_cookie] config to [ec] per spec §14
Implement Story 3 (#536): KV-backed identity graph with compare-and-swap concurrency, partner ID upserts, tombstone writes for consent withdrawal, and revive semantics. Includes schema types, metadata, 300s last-seen debounce, and comprehensive unit tests. Also incorporates earlier foundation work: EC module restructure, config migration from [edge_cookie] to [ec], cookie domain computation, consent gating fixes, and integration proxy revocation support.
Implement Story 4 (#537): partner KV store with API key hashing, POST /admin/partners/register with basic-auth protection, strict field validation (ID format, URL allowlists, domain normalization), and pull-sync config validation. Includes index-based API key lookup and comprehensive unit tests.
Implement Story 5 (#538): centralize EC cookie set/delete and KV tombstone writes in finalize_response(), replacing inline mutation scattered across publisher and proxy handlers. Adds consent-withdrawal cleanup, EC header propagation on proxy requests, and docs formatting.
Implement Story 8 (#541): POST /api/v1/sync with Bearer API key auth, per-partner rate limiting, batch size cap, per-mapping validation and rejection reasons, 200/207 response semantics, tolerant Bearer parsing, and KV-abort on store unavailability.
Implement Story 9 (#542): server-to-server pull sync that runs after send_to_client() on organic traffic only. Refactors the Fastly adapter entrypoint from #[fastly::main] to explicit Request::from_client() + send_to_client() to enable post-send background work. Pull sync enumerates pull-enabled partners, checks staleness against pull_sync_ttl_sec, validates URL hosts against the partner allowlist, enforces hourly rate limits, and dispatches concurrent outbound GETs with Bearer auth. Responses with uid:null or 404 are no-ops; valid UIDs are upserted into the identity graph. Includes EC ID format validation to prevent dispatch on spoofed values, partner list_registered() for KV store enumeration, and configurable pull_sync_concurrency (default 3).
Implement Story 11 (#544): Viceroy-driven E2E tests covering full EC lifecycle (generation, pixel sync, identify, batch sync, consent withdrawal, auth rejection). Adds EC test helpers with manual cookie tracking, minimal origin server with graceful shutdown, and required KV store fixtures. Fixes integration build env vars.
Consolidate is_valid_ec_hash and current_timestamp into single canonical definitions to eliminate copy-paste drift across the ec/ module tree. Fix serialization error variants in admin and batch_sync to use Ec instead of Configuration. Add scaling and design-decision documentation for partner store enumeration, rate limiter burstiness, and plaintext pull token storage. Use test constructors consistently in identify and finalize tests.
Questions for the TeamQ1: Admin auth model — Bearer token vs Basic auth (§13.2, §14.1, §17)The spec says Did the spec author know about the existing Basic Auth
Recommendation: Follow up with spec author to align. Q2:
|
- Rename ssc_hash → ec_hash in batch sync wire format (§9.3) - Strip x-ts-* prefix headers in copy_custom_headers (§15) - Strip dynamic x-ts-<partner_id> headers in clear_ec_on_response (§5.2) - Add PartnerNotFound and PartnerAuthFailed error variants (§16) - Rename Ec error variant → EdgeCookie (§16) - Validate EC IDs at read time, discard malformed values (§4.2) - Add rotating hourly offset for pull sync partner dispatch (§10.3) - Add _pull_enabled secondary index for O(1+N) pull sync reads (§13.1)
prk-Jr
left a comment
There was a problem hiding this comment.
Summary
This PR introduces the full EC identity subsystem — partner registry, pixel sync, batch sync, pull sync, consent gating, KV graph operations, and admin endpoints. The architecture is coherent and several design choices are genuinely good, but there are six blocking issues that must be addressed before merging: dead code masking a broken auth path, missing UID length validation, a missing request body size limit, a wrong HTTP status code, an unreachable error variant, and an underspecified test expectation.
Blocking
🔧 Dead code / broken auth
-
verify_api_keyis dead code — auth uses plain string equality:PartnerStore::verify_api_keyusessubtle::ConstantTimeEqand SHA-256 hashing. The actual auth path inbatch_sync.rshashes the token and callsfind_by_api_key_hash, which then does a plainrecord.api_key_hash != hashstring comparison (line 663). The constant-time method is never called. Either wireverify_api_keyinto the auth path or remove it; currently the timing-safe comparison exists only on paper. (crates/trusted-server-core/src/ec/partner.rsline 682,crates/trusted-server-core/src/ec/batch_sync.rsline 101) -
No UID length validation in batch sync or pixel sync:
SyncMapping.partner_uidis checked only for non-emptiness (line 206,batch_sync.rs). An unbounded UID written to KV can grow the entry beyond Fastly's limits or inflatex-ts-eidsunexpectedly. A max length (e.g. 512 bytes) should be validated here and insync_pixel.rsSyncQuery.uid. (crates/trusted-server-core/src/ec/batch_sync.rsline 206,crates/trusted-server-core/src/ec/sync_pixel.rsline 74) -
No request body size limit in admin partner registration:
handle_register_partnercallsreq.take_body_bytes()without a size cap (line 154). A malicious caller can send a very large payload; deserializing it amplifies memory pressure on the WASM instance. Apply a cap (e.g. 64 KiB) before deserialization. (crates/trusted-server-core/src/ec/admin.rsline 154)
🔧 Wrong HTTP status codes
-
PartnerNotFoundmaps to HTTP 400 instead of 404:IntoHttpResponse::status_codereturnsStatusCode::BAD_REQUESTforPartnerNotFound(line 114). A missing partner is a 404 Not Found, not a client input error. Returning 400 misleads callers into thinking the request was malformed. (crates/trusted-server-core/src/error.rsline 114) -
PartnerAuthFailedvariant is never instantiated: The variant exists in the error enum and maps toStatusCode::UNAUTHORIZED, but no call site in the codebase constructs it. Auth failures inbatch_sync.rsreturn early with inlineerror_response(StatusCode::UNAUTHORIZED, ...)instead. Either wire the variant into the auth path or remove it so the#[allow(dead_code)]suppression on the whole enum does not hide this. (crates/trusted-server-core/src/error.rsline 83)
❓ Ambiguous test expectation
IdentifyConsentDeniedaccepts both 403 and 204 — which is correct?: The test at line 625 accepts either status with the comment "may be 403 or 204 depending on whether the EC context reads the cookie before consent check." This means the test cannot detect a regression where one of these cases starts returning the wrong code. The spec should declare the single correct response for this scenario under Unknown jurisdiction with GPC=1, and the test should assert exactly that code. (crates/integration-tests/tests/frameworks/scenarios.rsline 625)
Non-blocking
👍 Praise
-
Two-phase EC lifecycle design (
read_from_request/generate_if_needed):EcContextcleanly separates the read phase (called on every request) from the write phase (called only in organic handlers). This prevents accidental EC generation on read-only endpoints like/identifyand makes the lifecycle explicit and auditable. (crates/trusted-server-core/src/ec/mod.rslines 147–275) -
CAS retry loop with conflict-safe tombstone revival:
KvIdentityGraph::create_or_revivehandles the race between a delete and a re-consent within the tombstone window usingif_generation_matchwith a bounded retry loop, re-reading the generation on each conflict and checking whether another writer already revived the entry. This is the correct way to handle optimistic concurrency on Fastly KV. (crates/trusted-server-core/src/ec/kv.rslines 251–328) -
O(1) auth lookup via hashed secondary index with stale-index guard:
find_by_api_key_hashresolvesapikey:{sha256_hex}→partner_id→PartnerRecordin two KV reads, then re-verifiesrecord.api_key_hash == hashto guard against stale entries from key rotation. This is the right defense in depth for an eventually-consistent secondary index. (crates/trusted-server-core/src/ec/partner.rslines 621–672) -
Consent gating is applied consistently at every sync write: Both pixel sync (
sync_pixel.rsline 62) and batch sync (viaUpsertResult::ConsentWithdrawnfromkv.rsline 474) check consent before writing to KV. Tombstone entries block upserts at the KV layer, so a late-arriving sync cannot repopulate partner IDs after withdrawal. The defense is applied at the correct architectural boundary. -
KvMetadatasidecar avoids streaming the full KV body for consent checks: Theokandcountryfields are duplicated in the 2048-byte metadata slot so that future batch sync fast paths can check consent without deserializing the full entry body. Themetadata_fits_in_2048_bytestest guards the size contract. (crates/trusted-server-core/src/ec/kv_types.rslines 77–89, 278–291)
♻️ Refactor
-
encode_eids_headertruncation is O(N²): The function iterates fromNdown to0, re-serializing and base64-encoding the full slice on each attempt. For a modest N (e.g. 64 partners), this is 64 serialization passes. A faster approach: binary-search onsize, or serialize incrementally and measure as you go. (crates/trusted-server-core/src/ec/eids.rsline 117) -
upsert()write order leaves stale secondary index on interruption: The documented order writes theapikey:index before the primary record. If the process is interrupted between those two writes, the index points to a partner ID that has not yet been persisted. Swapping the order (primary first, then index) would leave a missing index entry (self-healing on next auth lookup) rather than a dangling one. (crates/trusted-server-core/src/ec/partner.rslines 443–453) -
list_registered()is O(N) KV reads per pull sync dispatch: Each organic request post-send enumerates all partner keys and reads each one individually. The_pull_enabledsecondary index already exists to avoid this scan —dispatch_pull_syncshould callpull_enabled_partners()rather thanlist_registered(). (crates/trusted-server-core/src/ec/partner.rsline 297)
🌱 Seedling
-
No cap on partner ID count per KV entry: Each pixel/batch/pull sync writes a new key into
entry.ids. With no upper bound, a KV entry can grow unbounded (Fastly KV enforces a 1 MB value limit). Consider cappingidsat a configurable maximum (e.g. 50 partners). (crates/trusted-server-core/src/ec/kv.rsline 400) -
Client IP forwarded to partner pull-sync endpoints:
PullSyncContextstores and re-exports the client IP for use in pull sync query parameters. Forwarding a user's IP to a third-party partner endpoint is a privacy/GDPR concern that should be documented and reviewed against the publisher's data processing agreement. (crates/trusted-server-core/src/ec/pull_sync.rslines 25–42) -
Tombstone does not preserve original
createdtimestamp:KvEntry::tombstoneresetscreatedtonow(the withdrawal timestamp). This loses audit information about when the identity was originally established, which may be needed for GDPR records of processing. The doc comment acknowledges the tradeoff but the decision should be revisited. (crates/trusted-server-core/src/ec/kv_types.rsline 152) -
current_timestamp()silently returns epoch 0 on failure: IfSystemTime::now()fails (unusual on wasm32-wasip1 but possible in certain test harnesses), the function returns0. A timestamp of 0 passed tosyncedcomparisons could cause all subsequent sync writes to appear stale. Consider logging a warning on the fallback path. (crates/trusted-server-core/src/ec/mod.rsline 425)
📝 Note
-
No
Access-Control-Max-Ageon/identifypreflight responses:cors_preflight_identifysetsAccess-Control-Allow-Origin,Allow-Methods,Allow-Headers, andVarybut omitsAccess-Control-Max-Age. Without it, browsers use a very short default (5 seconds in Chrome), causing a preflight on every identify call from browser JS. AddingAccess-Control-Max-Age: 600would reduce preflight overhead significantly. (crates/trusted-server-core/src/ec/identify.rsline 209) -
7 missing integration test scenarios: The following EC scenarios have no integration test: pixel sync rate limiting, batch sync with
ec_hash_not_found, batch sync withconsent_withdrawn, batch sync size limit (> 1000 mappings),PartnerNotFoundon pixel sync returns 400, admin registration validation errors, and pull sync dispatch. Each is a distinct failure mode that integration tests should cover.
⛏ Nitpick
-
normalize_ipIPv6 output format is undocumented as a stable contract: The function concatenates the first 4 segments as zero-padded hex without separators (e.g."20010db885a30000"). If this format is stored in KV or logged for debugging, changing it would invalidate all existing EC hashes. Consider adding a doc comment explicitly marking the format as stable. (crates/trusted-server-core/src/ec/generation.rsline 27) -
#[allow(dead_code)]on the entire error enum masks unused variants: The attribute onTrustedServerError(line 15) suppresses warnings for all variants, includingPartnerAuthFailedwhich is genuinely unused. Apply the attribute per-variant only where needed, or remove it from the enum level so the compiler can surface real dead code. (crates/trusted-server-core/src/error.rsline 15)
📌 Out of scope
[synthetic]→[ec]config migration is a breaking deployment change: Any deployment that hastrusted-server.tomlwith the old[synthetic]section will fail to start after this PR is deployed, unless the config is updated atomically. The migration should be documented in the PR description and coordinated with operators. This is a deployment concern, not a code defect.
CI Status
- integration tests: PASS
- browser integration tests: PASS
- prepare integration artifacts: PASS
| /// # Errors | ||
| /// | ||
| /// Returns [`TrustedServerError::KvStore`] if the partner lookup fails. | ||
| pub fn verify_api_key( |
There was a problem hiding this comment.
🔧 Dead code — verify_api_key is never called
This method uses subtle::ConstantTimeEq for timing-safe comparison, but the actual auth path in batch_sync.rs hashes the token and calls find_by_api_key_hash, which then does a plain record.api_key_hash != hash string comparison (line 663). The constant-time protection exists only on paper.
Fix: Either route batch sync auth through verify_api_key, or remove this method. If find_by_api_key_hash remains the auth path, apply constant-time comparison there:
use subtle::ConstantTimeEq;
if !record.api_key_hash.as_bytes().ct_eq(hash.as_bytes()).into() {
return Ok(None);
}| continue; | ||
| } | ||
|
|
||
| if mapping.partner_uid.is_empty() { |
There was a problem hiding this comment.
🔧 No UID length validation
partner_uid is checked only for non-emptiness. An unbounded value written to the KV identity graph can grow the entry beyond Fastly's 1 MB limit and inflate x-ts-eids headers unpredictably.
Fix: Add a maximum length check:
const MAX_PARTNER_UID_BYTES: usize = 512;
if mapping.partner_uid.is_empty() || mapping.partner_uid.len() > MAX_PARTNER_UID_BYTES {
errors.push(MappingError { index: idx, reason: REASON_INVALID_PARTNER_UID });
continue;
}The same cap should be applied in sync_pixel.rs.
| mut req: Request, | ||
| ) -> Result<Response, Report<TrustedServerError>> { | ||
| // Parse request body. | ||
| let body_bytes = req.take_body_bytes(); |
There was a problem hiding this comment.
🔧 No request body size limit
req.take_body_bytes() reads the entire body before deserialization. A malicious caller can send an arbitrarily large payload, amplifying memory pressure on the WASM instance.
Fix: Cap the body before deserializing:
const MAX_REGISTER_BODY_BYTES: usize = 64 * 1024; // 64 KiB
let body_bytes = req.take_body_bytes();
if body_bytes.len() > MAX_REGISTER_BODY_BYTES {
return Err(bad_request("request body too large"));
}| Self::Proxy { .. } => StatusCode::BAD_GATEWAY, | ||
| Self::Ec { .. } => StatusCode::INTERNAL_SERVER_ERROR, | ||
| Self::EdgeCookie { .. } => StatusCode::INTERNAL_SERVER_ERROR, | ||
| Self::PartnerNotFound { .. } => StatusCode::BAD_REQUEST, |
There was a problem hiding this comment.
🔧 PartnerNotFound should map to 404, not 400
A missing partner is a lookup failure, not a malformed request. Returning 400 misleads callers into thinking their request was syntactically invalid.
Fix:
Self::PartnerNotFound { .. } => StatusCode::NOT_FOUND,|
|
||
| /// Partner authentication failed (invalid or missing credentials). | ||
| #[display("Partner auth failed: {partner_id}")] | ||
| PartnerAuthFailed { partner_id: String }, |
There was a problem hiding this comment.
🔧 PartnerAuthFailed variant is never instantiated
No call site constructs TrustedServerError::PartnerAuthFailed. Auth failures in batch_sync.rs return early with error_response(StatusCode::UNAUTHORIZED, ...) directly, bypassing this variant entirely.
Fix: Either route auth failures through this variant so the status-code mapping in IntoHttpResponse is actually used, or remove the variant. Also remove the enum-level #[allow(dead_code)] and apply it per-variant only where genuinely needed so the compiler can surface dead code.
| /// | ||
| /// Returns an error if JSON serialization fails. | ||
| pub fn encode_eids_header(eids: &[Eid]) -> Result<(String, bool), Report<TrustedServerError>> { | ||
| for size in (0..=eids.len()).rev() { |
There was a problem hiding this comment.
♻️ O(N²) truncation — re-serializes the full slice on every attempt
The loop iterates from N down to 0, calling serde_json::to_vec and BASE64.encode on each pass. For 64 partners this is 64 full serializations.
For the expected partner counts this is unlikely to be a hot-path bottleneck, but worth a comment noting the complexity. Binary search on size would reduce to O(N log N) if this ever becomes a concern.
| std::time::SystemTime::now() | ||
| .duration_since(std::time::UNIX_EPOCH) | ||
| .map(|d| d.as_secs()) | ||
| .unwrap_or(0) |
There was a problem hiding this comment.
🌱 current_timestamp() silently returns epoch 0 on failure
If SystemTime::now() returns an error, the function returns 0. A synced timestamp of 0 would cause all subsequent sync writes to appear stale, silently breaking the identity graph without any log output.
Suggestion: Log a warning on the fallback path:
.unwrap_or_else(|_| {
log::warn!("SystemTime::now() failed — using epoch 0 as timestamp fallback");
0
})| /// preserved — reading the existing entry first would add latency on | ||
| /// the consent-withdrawal hot path, and the tombstone expires in 24h. | ||
| #[must_use] | ||
| pub fn tombstone(now: u64) -> Self { |
There was a problem hiding this comment.
🌱 Tombstone does not preserve the original created timestamp
The doc comment acknowledges this tradeoff. If GDPR records of processing require knowing when an identity was originally established, this information is lost when the tombstone overwrites created. Consider reading the existing entry before writing the tombstone and preserving created when available.
| CorsDecision::Denied | ||
| } | ||
|
|
||
| fn apply_cors_headers(response: &mut Response, origin: &str) { |
There was a problem hiding this comment.
📝 Missing Access-Control-Max-Age on preflight responses
Without this header, browsers use a very short default preflight cache lifetime (5 seconds in Chrome), causing a preflight on every identify call from browser JS.
Suggestion: Add to apply_cors_headers:
response.set_header("access-control-max-age", "600");| /// where devices rotate their interface identifier (lower 64 bits). | ||
| /// The first 4 segments are hex-encoded without separators. | ||
| /// IPv4 addresses are returned unchanged. | ||
| fn normalize_ip(ip: IpAddr) -> String { |
There was a problem hiding this comment.
⛏ normalize_ip IPv6 output format is undocumented as a stable contract
The format (4 segments concatenated as zero-padded hex without separators, e.g. "20010db885a30000") is used as HMAC input and therefore determines the EC hash. Changing the format would invalidate all existing EC cookies. Consider adding an explicit stability note to the doc comment.
Summary
[edge_cookie]to[ec], restructure code intoec/module tree, centralize cookie/response finalization in middleware, and refactor the Fastly adapter entrypoint for post-send background work.Changes
crates/trusted-server-adapter-fastly/src/main.rsRequest::from_client()+send_to_client()for post-send pull sync; added EC route handlers (/sync,/identify,/api/v1/sync,/admin/partners/register);RouteOutcomestruct for organic-route trackingcrates/trusted-server-core/src/ec/mod.rsEcContextstruct with two-phase lifecycle (read_from_request+generate_if_needed), consent integration, EC hash extractioncrates/trusted-server-core/src/ec/generation.rs{64hex}.{6alnum}), IP normalization, format validationcrates/trusted-server-core/src/ec/cookies.rspublisher.domaincrates/trusted-server-core/src/ec/consent.rscrates/trusted-server-core/src/ec/kv.rscreate_or_revive,upsert_partner_id, tombstone writes, 300s last-seen debouncecrates/trusted-server-core/src/ec/kv_types.rsKvEntry,KvConsent,KvGeo,KvPartnerId,KvMetadatacrates/trusted-server-core/src/ec/partner.rsPartnerStorewith API key hash verification,list_registered(), pull sync config validationcrates/trusted-server-core/src/ec/admin.rsPOST /admin/partners/registerwith field validation, domain normalization, pull sync config checkscrates/trusted-server-core/src/ec/sync_pixel.rsGET /syncpixel endpoint: query validation, partner lookup, return-URL domain check, consent fallback, rate limiting, KV upsertcrates/trusted-server-core/src/ec/identify.rsGET /identifyendpoint: response matrix (200/204/403), CORS validation, EID headers, per-partner UID headerscrates/trusted-server-core/src/ec/batch_sync.rsPOST /api/v1/syncbatch endpoint: Bearer auth, per-mapping validation, 200/207 semantics, rate limitingcrates/trusted-server-core/src/ec/pull_sync.rscrates/trusted-server-core/src/ec/eids.rscrates/trusted-server-core/src/ec/finalize.rsfinalize_response(): cookie set/delete, KV tombstone writes, header propagationcrates/trusted-server-core/src/auction/endpoints.rscrates/trusted-server-core/src/auction/formats.rsx-ts-eids,x-ts-ec-consent, per-partner headerscrates/trusted-server-core/src/settings.rs[ec]config section with passphrase, store names,pull_sync_concurrency; placeholder rejectioncrates/trusted-server-core/src/edge_cookie.rsec/module tree)crates/integration-tests/tests/common/ec.rscrates/integration-tests/tests/frameworks/scenarios.rscrates/integration-tests/tests/integration.rstest_ec_lifecycle_fastlytest runnerscripts/integration-tests.shTRUSTED_SERVER__EC__PASSPHRASEscripts/integration-tests-browser.sh.github/actions/setup-integration-test-env/action.ymlCloses
Closes #533, Closes #535, Closes #536, Closes #537, Closes #538, Closes #539, Closes #540, Closes #541, Closes #542, Closes #543, Closes #544
Test plan
cargo test --workspacecargo clippy --workspace --all-targets --all-features -- -D warningscargo fmt --all -- --checkcd crates/js/lib && npx vitest runcargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1fastly compute serve— full lifecycle verified (EC generation → partner registration → pixel sync → identify → batch sync → consent withdrawal)cargo test --manifest-path crates/integration-tests/Cargo.toml -- --include-ignored test_ec_lifecycle_fastly(7 scenarios pass)EC Manual Testing Plan
Manual testing guide for the Edge Cookie (EC) identity system.
Run these tests against a local Viceroy instance with the full EC branch stack applied.
Prerequisites
1. Build the WASM binary
2. Start a minimal origin server
3. Start Viceroy
The server is now at
http://127.0.0.1:7676.4. Test credentials
Links
- Edge Cookie Epic Story Status
# EC Manual Testing PlanManual testing guide for the Edge Cookie (EC) identity system.
Run these tests against a local Viceroy instance with the full EC branch stack applied.
Prerequisites
1. Build the WASM binary
2. Start a minimal origin server
3. Start Viceroy
The server is now at
http://127.0.0.1:7676.4. Test credentials
/admin/*admin/changeme/api/v1/syncapi_keyvalue you used when registering a partnerTest 1: EC Cookie Generation (organic page load)
What you're testing: First visit to an organic route generates an EC cookie.
Steps:
http://127.0.0.1:7676/(or any non-EC path)What to look for:
Set-Cookiecontainsts-ec=<64hex>.<6alnum>; Domain=.test-publisher.com; Path=/; Secure; SameSite=Lax; Max-Age=31536000x-ts-ecechoes the same EC ID valuets-eccookie is present (note: may not appear on127.0.0.1due to domain mismatch — check response headers instead)200(origin content proxied successfully)With curl:
Expected:
Second request with the same EC (returning user):
x-ts-echeader echoed backSet-Cookieif the cookie is unchanged (or re-sets same value)Test 2: Partner Registration
What you're testing: Register a partner for use in sync tests.
What to look for:
201 Createdwith JSON body containing"created": true"id": "ssp_test","bidstream_enabled": trueapi_keyorapi_key_hash(sensitive fields redacted)Error cases to verify:
401withWWW-Authenticate: BasicheaderTest 3: Pixel Sync (
GET /sync)What you're testing: Partner pixel fires and writes a UID into the identity graph.
Prerequisites: Complete Test 1 (have an EC ID) and Test 2 (have a registered partner).
What to look for:
302 FoundLocationheader ishttps://sync.example.com/done?ts_synced=1Error cases:
302to return URL with?ts_synced=0&ts_reason=no_ec400400Test 4: Identity Lookup (
GET /identify)What you're testing: After syncing, the identity graph returns partner UIDs.
Prerequisites: Complete Test 3 (have a synced UID).
What to look for:
200{"ec":"<ec_id>","consent":"ok","degraded":false,"uids":{"ssp_test":"user-abc-123"},"eids":[...]}x-ts-ec: <ec_id>x-ts-ec-consent: okx-ts-eids: <base64 string>x-ts-ssp_test: user-abc-123No EC cookie:
204 No Content(empty body)With CORS origin:
Access-Control-Allow-Origin: https://test-publisher.comAccess-Control-Allow-Credentials: trueVary: OriginCORS preflight:
204Non-publisher origin → rejection:
403Test 5: S2S Batch Sync (
POST /api/v1/sync)What you're testing: Server-to-server batch UID writes via authenticated API.
Prerequisites: Test 2 (have a partner with known
api_key).First, get an EC hash (the 64-hex prefix before the dot):
What to look for:
200{"accepted":1,"rejected":0,"errors":[]}curl -H "Cookie: ts-ec=$EC_ID" http://127.0.0.1:7676/identifynow shows"ssp_test":"batch-uid-99"(overwritten from pixel sync)Error cases:
401with{"error":"invalid_token"}401200withrejected: 1anderrors: [{"index":0,"reason":"invalid_ec_hash"}]Test 6: Consent Withdrawal (GPC)
What you're testing:
Sec-GPC: 1header triggers EC cookie deletion.What to look for:
Set-Cookie: ts-ec=; Domain=.test-publisher.com; Path=/; Secure; SameSite=Lax; Max-Age=0Max-Age=0means the cookie is being deletedThen verify identify is denied:
403with body{"consent":"denied"}Test 7: Auction Bidstream Decoration
What you're testing: Auction responses include EC identity headers.
What to look for in response headers:
x-ts-ec: <ec_id>— EC ID is propagatedx-ts-ec-consent: ok— consent statusx-ts-eids: <base64>— EID array (if any partner syncs exist)x-ts-ssp_test: <uid>— per-partner UID headers (if synced)Test 8: Pull Sync (server logs only)
What you're testing: Background pull sync fires after organic requests.
Register a pull-enabled partner:
Then make an organic request and check server logs (stderr):
curl -H "Cookie: ts-ec=$EC_ID" http://127.0.0.1:7676/What to look for in Viceroy stderr:
Pull sync: enumerated N partners (M pull-enabled)— confirms dispatch was attempted/sync,/identify,/auction, or/adminpathsVerify pull sync does NOT fire on non-organic routes:
Quick Full-Loop Script
Run the full lifecycle in one shot:
What to Watch in Server Logs
Set
RUST_LOG=debugwhen starting Viceroy for maximum visibility:EC generated for clientProxying request to publisher_originPull sync: enumerated N partnersPull sync: rate-limited partnerKV CAS conflict, retryingKV entry not found for hashSync pixel: upsert partnerBatch sync: processing N mappingsfinalize_response: setting EC cookiefinalize_response: expiring EC cookieChecklist
unwrap()in production code — useexpect("should ...")tracingmacros (notprintln!)