Skip to content

Commit e1405e8

Browse files
Address PR review: UID length guard, ec_id decoupling, validation, docs
- Add MAX_UID_LENGTH check to extract_fp_signals, matching all other sync paths (pixel, batch, pull) that enforce the 512-byte limit - Decouple ec_id from PullSyncContext in RouteOutcome so first-party signal writes succeed even when client_ip is unavailable - Add current_timestamp_ms() helper to ec module, replace inline SystemTime boilerplate in adapter - Add 128-char max length validation for fp_signal_cookie_names - Fix partner.rs doc comments referencing nonexistent fp_signal_enabled field — now correctly references fp_signal_cookie_names - Document TTL default behavior in filter_stale_signals - Fix Prettier formatting on two doc files Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6879b4c commit e1405e8

6 files changed

Lines changed: 156 additions & 55 deletions

File tree

crates/trusted-server-adapter-fastly/src/main.rs

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use trusted_server_core::constants::{
1212
};
1313
use trusted_server_core::ec::admin::handle_register_partner;
1414
use trusted_server_core::ec::batch_sync::handle_batch_sync;
15+
use trusted_server_core::ec::current_timestamp_ms;
1516
use trusted_server_core::ec::device::DeviceSignals;
1617
use trusted_server_core::ec::finalize::ec_finalize_response;
1718
use trusted_server_core::ec::fp_signals::{
@@ -111,21 +112,19 @@ fn main() -> Result<(), Error> {
111112
let RouteOutcome {
112113
response,
113114
pull_sync_context,
115+
ec_id,
114116
fp_signals,
115117
fp_signal_configs,
116118
} = outcome;
117119

118120
response.send_to_client();
119121

120122
// Write first-party signals to KV (post-send, off critical path).
123+
// Uses ec_id directly — not gated on PullSyncContext — so signals are
124+
// still written when client_ip is unavailable.
121125
if !fp_signals.is_empty() {
122-
if let Some(ref ctx) = pull_sync_context {
123-
run_fp_signal_collection_after_send(
124-
&settings,
125-
ctx.ec_id(),
126-
&fp_signals,
127-
&fp_signal_configs,
128-
);
126+
if let Some(ref ec_id) = ec_id {
127+
run_fp_signal_collection_after_send(&settings, ec_id, &fp_signals, &fp_signal_configs);
129128
}
130129
}
131130

@@ -140,6 +139,10 @@ fn main() -> Result<(), Error> {
140139
struct RouteOutcome {
141140
response: Response,
142141
pull_sync_context: Option<PullSyncContext>,
142+
/// The active EC ID, carried independently from [`PullSyncContext`] so
143+
/// that FP signal writes succeed even when `client_ip` is unavailable
144+
/// (which makes `PullSyncContext` `None`).
145+
ec_id: Option<String>,
143146
fp_signals: Vec<FpSignal>,
144147
fp_signal_configs: Vec<FpSignalPartnerConfig>,
145148
}
@@ -189,6 +192,7 @@ async fn route_request(
189192
return Ok(RouteOutcome {
190193
response,
191194
pull_sync_context: None,
195+
ec_id: None,
192196
fp_signals: vec![],
193197
fp_signal_configs: vec![],
194198
});
@@ -203,6 +207,7 @@ async fn route_request(
203207
return Ok(RouteOutcome {
204208
response,
205209
pull_sync_context: None,
210+
ec_id: None,
206211
fp_signals: vec![],
207212
fp_signal_configs: vec![],
208213
});
@@ -233,6 +238,7 @@ async fn route_request(
233238
return Ok(RouteOutcome {
234239
response,
235240
pull_sync_context: None,
241+
ec_id: None,
236242
fp_signals: vec![],
237243
fp_signal_configs: vec![],
238244
});
@@ -245,6 +251,7 @@ async fn route_request(
245251
return Ok(RouteOutcome {
246252
response,
247253
pull_sync_context: None,
254+
ec_id: None,
248255
fp_signals: vec![],
249256
fp_signal_configs: vec![],
250257
});
@@ -392,10 +399,7 @@ async fn route_request(
392399
if configs.is_empty() {
393400
(vec![], vec![])
394401
} else {
395-
let now_ms = std::time::SystemTime::now()
396-
.duration_since(std::time::UNIX_EPOCH)
397-
.map(|d| d.as_millis() as u64)
398-
.unwrap_or(0);
402+
let now_ms = current_timestamp_ms();
399403
let signals = ec_context
400404
.cookie_jar()
401405
.map(|j| extract_fp_signals(j, &configs, now_ms))
@@ -415,9 +419,12 @@ async fn route_request(
415419
None
416420
};
417421

422+
let ec_id = ec_context.ec_value().map(str::to_owned);
423+
418424
Ok(RouteOutcome {
419425
response,
420426
pull_sync_context,
427+
ec_id,
421428
fp_signals,
422429
fp_signal_configs,
423430
})

crates/trusted-server-core/src/ec/fp_signals.rs

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use error_stack::{Report, ResultExt};
1818
use serde::{Deserialize, Serialize};
1919

2020
use super::kv::{CasWriteOutcome, KvIdentityGraph};
21-
use super::kv_types::KvPartnerId;
21+
use super::kv_types::{KvPartnerId, MAX_UID_LENGTH};
2222
use super::log_id;
2323

2424
/// Per-partner configuration for first-party signal extraction.
@@ -184,6 +184,15 @@ pub fn extract_fp_signals(
184184
continue;
185185
}
186186

187+
if uid.len() > MAX_UID_LENGTH {
188+
log::debug!(
189+
"Skipping fp_signal for partner '{}': UID exceeds {MAX_UID_LENGTH} bytes (got {})",
190+
config.partner_id,
191+
uid.len(),
192+
);
193+
continue;
194+
}
195+
187196
signals.push(FpSignal {
188197
partner_id: config.partner_id.clone(),
189198
uid,
@@ -216,6 +225,9 @@ fn filter_stale_signals<'a>(
216225
signals
217226
.iter()
218227
.filter(|signal| {
228+
// A missing TTL means the partner config was removed between
229+
// extraction and write. Default to 0 (always refresh) since
230+
// the signal was legitimately extracted with a valid config.
219231
let ttl = ttl_by_partner
220232
.get(signal.partner_id.as_str())
221233
.copied()
@@ -869,6 +881,42 @@ mod tests {
869881
);
870882
}
871883

884+
#[test]
885+
fn extract_fp_signals_skips_uid_exceeding_max_length() {
886+
// Arrange — cookie value exceeds MAX_UID_LENGTH (512 bytes)
887+
let long_uid = "x".repeat(MAX_UID_LENGTH + 1);
888+
let jar = jar_with(&[("partner_cookie", &long_uid)]);
889+
let configs = [raw_config("partner", &["partner_cookie"])];
890+
891+
// Act
892+
let signals = extract_fp_signals(&jar, &configs, 0);
893+
894+
// Assert
895+
assert!(
896+
signals.is_empty(),
897+
"should skip signal with UID exceeding MAX_UID_LENGTH"
898+
);
899+
}
900+
901+
#[test]
902+
fn extract_fp_signals_accepts_uid_at_max_length() {
903+
// Arrange — cookie value is exactly MAX_UID_LENGTH (512 bytes)
904+
let uid = "x".repeat(MAX_UID_LENGTH);
905+
let jar = jar_with(&[("partner_cookie", &uid)]);
906+
let configs = [raw_config("partner", &["partner_cookie"])];
907+
908+
// Act
909+
let signals = extract_fp_signals(&jar, &configs, 0);
910+
911+
// Assert
912+
assert_eq!(
913+
signals.len(),
914+
1,
915+
"should accept signal at exactly MAX_UID_LENGTH"
916+
);
917+
assert_eq!(signals[0].uid, uid, "should use the full UID value");
918+
}
919+
872920
#[test]
873921
fn extract_fp_signals_raw_config_with_expired_identity_expires_skipped() {
874922
// Arrange — a partner with json_path: None whose cookie value happens to be

crates/trusted-server-core/src/ec/mod.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,21 @@ pub(crate) fn current_timestamp() -> u64 {
495495
})
496496
}
497497

498+
/// Returns the current Unix timestamp in milliseconds.
499+
///
500+
/// Uses `std::time::SystemTime` which is supported on `wasm32-wasip1`.
501+
/// The `as u64` narrowing from `u128` is safe for ~584 million years.
502+
#[must_use]
503+
pub fn current_timestamp_ms() -> u64 {
504+
std::time::SystemTime::now()
505+
.duration_since(std::time::UNIX_EPOCH)
506+
.map(|d| d.as_millis() as u64)
507+
.unwrap_or_else(|err| {
508+
log::error!("SystemTime::now() failed, falling back to epoch 0: {err}");
509+
0
510+
})
511+
}
512+
498513
#[cfg(test)]
499514
mod tests {
500515
use super::*;

crates/trusted-server-core/src/ec/partner.rs

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@
88
//! - `_pull_enabled` — JSON array of partner IDs with `pull_sync_enabled:
99
//! true`, enabling O(1+N) reads on the pull sync hot path instead of a
1010
//! full partner scan.
11-
//! - `_fp_signal_enabled` — JSON array of partner IDs with
12-
//! `fp_signal_enabled: true`, enabling O(1+N) reads for first-party
13-
//! signal collection config without a full partner scan.
11+
//! - `_fp_signal_enabled` — JSON array of partner configs for partners
12+
//! with at least one `fp_signal_cookie_names` entry, enabling O(1+N)
13+
//! reads for first-party signal collection config without a full
14+
//! partner scan.
1415
1516
use std::{collections::HashSet, sync::OnceLock};
1617

@@ -257,10 +258,17 @@ pub fn validate_fp_signal_config(record: &PartnerRecord) -> Result<(), String> {
257258
));
258259
}
259260

261+
const MAX_COOKIE_NAME_LENGTH: usize = 128;
260262
for name in &record.fp_signal_cookie_names {
261263
if name.is_empty() {
262264
return Err("fp_signal_cookie_names entries must not be empty".to_owned());
263265
}
266+
if name.len() > MAX_COOKIE_NAME_LENGTH {
267+
return Err(format!(
268+
"fp_signal_cookie_names entry must be at most {MAX_COOKIE_NAME_LENGTH} characters, got {}",
269+
name.len()
270+
));
271+
}
264272
if !name.is_ascii() {
265273
return Err(format!(
266274
"fp_signal_cookie_names entry '{name}' must contain only ASCII characters"
@@ -323,9 +331,9 @@ pub fn hash_api_key(api_key: &str) -> String {
323331
/// O(1) auth lookups during batch sync.
324332
/// - `_pull_enabled` stores a JSON array of partner IDs with
325333
/// `pull_sync_enabled: true` for O(1+N) reads during pull sync dispatch.
326-
/// - `_fp_signal_enabled` stores a JSON array of partner IDs with
327-
/// `fp_signal_enabled: true` for O(1+N) reads during first-party signal
328-
/// collection.
334+
/// - `_fp_signal_enabled` stores a JSON array of partner configs for
335+
/// partners with at least one `fp_signal_cookie_names` entry for
336+
/// O(1+N) reads during first-party signal collection.
329337
pub struct PartnerStore {
330338
store_name: String,
331339
}
@@ -1314,4 +1322,16 @@ mod tests {
13141322
"should mention 4-segment limit, got: {err}"
13151323
);
13161324
}
1325+
1326+
#[test]
1327+
fn validate_fp_signal_rejects_cookie_name_too_long() {
1328+
let mut record = base_fp_signal_record();
1329+
record.fp_signal_cookie_names = vec!["x".repeat(129)];
1330+
let err = validate_fp_signal_config(&record)
1331+
.expect_err("should reject cookie name exceeding 128 chars");
1332+
assert!(
1333+
err.contains("at most 128"),
1334+
"should mention 128-char limit, got: {err}"
1335+
);
1336+
}
13171337
}

docs/superpowers/plans/2026-04-09-first-party-signal-collection.md

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,20 @@
1212

1313
## File Structure
1414

15-
| File | Responsibility |
16-
|------|---------------|
17-
| `crates/trusted-server-core/src/ec/fp_signals.rs` | **New.** Types (`FpSignalPartnerConfig`, `FpSignal`, `FpSignalError`), extraction logic, JSON path walker, batched CAS write, all unit tests. |
18-
| `crates/trusted-server-core/src/ec/mod.rs` | Declare `pub mod fp_signals`. Add `cookie_jar` field to `EcContext`, store it during `read_from_request_with_geo`, expose via `cookie_jar()` accessor. |
19-
| `crates/trusted-server-core/src/ec/partner.rs` | Add 3 FP signal fields to `PartnerRecord`. Add `FP_SIGNAL_INDEX_KEY` constant. Add `fp_signal_configs()` accessor. Add `update_fp_signal_index()` and call it from `upsert()`. Skip new index key in `list_registered()`. Add validation function. |
20-
| `crates/trusted-server-core/src/ec/admin.rs` | Add 3 FP signal fields to `RegisterPartnerRequest`. Wire them into `PartnerRecord` construction and validation. |
21-
| `crates/trusted-server-adapter-fastly/src/main.rs` | Expand `RouteOutcome` to carry `fp_signals` and `fp_signal_configs`. Extract signals pre-send. Write signals post-send via `run_fp_signal_collection_after_send()`. |
15+
| File | Responsibility |
16+
| -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
17+
| `crates/trusted-server-core/src/ec/fp_signals.rs` | **New.** Types (`FpSignalPartnerConfig`, `FpSignal`, `FpSignalError`), extraction logic, JSON path walker, batched CAS write, all unit tests. |
18+
| `crates/trusted-server-core/src/ec/mod.rs` | Declare `pub mod fp_signals`. Add `cookie_jar` field to `EcContext`, store it during `read_from_request_with_geo`, expose via `cookie_jar()` accessor. |
19+
| `crates/trusted-server-core/src/ec/partner.rs` | Add 3 FP signal fields to `PartnerRecord`. Add `FP_SIGNAL_INDEX_KEY` constant. Add `fp_signal_configs()` accessor. Add `update_fp_signal_index()` and call it from `upsert()`. Skip new index key in `list_registered()`. Add validation function. |
20+
| `crates/trusted-server-core/src/ec/admin.rs` | Add 3 FP signal fields to `RegisterPartnerRequest`. Wire them into `PartnerRecord` construction and validation. |
21+
| `crates/trusted-server-adapter-fastly/src/main.rs` | Expand `RouteOutcome` to carry `fp_signals` and `fp_signal_configs`. Extract signals pre-send. Write signals post-send via `run_fp_signal_collection_after_send()`. |
2222

2323
---
2424

2525
### Task 1: Add `FpSignalPartnerConfig` and `FpSignal` types
2626

2727
**Files:**
28+
2829
- Create: `crates/trusted-server-core/src/ec/fp_signals.rs`
2930
- Modify: `crates/trusted-server-core/src/ec/mod.rs:30-43`
3031

@@ -108,6 +109,7 @@ git commit -m "Add FpSignalPartnerConfig, FpSignal, and FpSignalError types"
108109
### Task 2: Implement JSON path extraction with tests
109110

110111
**Files:**
112+
111113
- Modify: `crates/trusted-server-core/src/ec/fp_signals.rs`
112114
- Test: `crates/trusted-server-core/src/ec/fp_signals.rs` (inline `#[cfg(test)]`)
113115

@@ -236,6 +238,7 @@ git commit -m "Implement JSON path extraction for first-party signal cookies"
236238
### Task 3: Implement `extract_fp_signals` with tests
237239

238240
**Files:**
241+
239242
- Modify: `crates/trusted-server-core/src/ec/fp_signals.rs`
240243

241244
- [ ] **Step 1: Write failing tests for cookie extraction**
@@ -518,6 +521,7 @@ git commit -m "Implement first-party signal extraction from cookie jar"
518521
### Task 4: Add UID2 expiry tests
519522

520523
**Files:**
524+
521525
- Modify: `crates/trusted-server-core/src/ec/fp_signals.rs`
522526

523527
- [ ] **Step 1: Write UID2 expiry tests**
@@ -603,6 +607,7 @@ git commit -m "Add UID2 expiry check tests for first-party signal extraction"
603607
### Task 5: Expose `CookieJar` from `EcContext`
604608

605609
**Files:**
610+
606611
- Modify: `crates/trusted-server-core/src/ec/mod.rs:139-161` (struct), `193-238` (read_from_request_with_geo), `407-464` (test helpers)
607612

608613
- [ ] **Step 1: Add `cookie_jar` field to `EcContext` struct**
@@ -659,6 +664,7 @@ git commit -m "Expose parsed CookieJar from EcContext for signal extraction"
659664
### Task 6: Add FP signal fields to `PartnerRecord`
660665

661666
**Files:**
667+
662668
- Modify: `crates/trusted-server-core/src/ec/partner.rs:62-115` (struct), `707-984` (tests)
663669

664670
- [ ] **Step 1: Add 3 fields to `PartnerRecord`**
@@ -721,6 +727,7 @@ git commit -m "Add first-party signal fields to PartnerRecord"
721727
### Task 7: Add FP signal validation
722728

723729
**Files:**
730+
724731
- Modify: `crates/trusted-server-core/src/ec/partner.rs`
725732

726733
- [ ] **Step 1: Write failing validation tests**
@@ -1012,6 +1019,7 @@ git commit -m "Add first-party signal validation for PartnerRecord"
10121019
### Task 8: Add `_fp_signal_enabled` index to `PartnerStore`
10131020

10141021
**Files:**
1022+
10151023
- Modify: `crates/trusted-server-core/src/ec/partner.rs`
10161024

10171025
- [ ] **Step 1: Add the index constant and skip it in `list_registered`**
@@ -1162,6 +1170,7 @@ git commit -m "Add _fp_signal_enabled index to PartnerStore"
11621170
### Task 9: Add FP signal fields to admin registration endpoint
11631171

11641172
**Files:**
1173+
11651174
- Modify: `crates/trusted-server-core/src/ec/admin.rs`
11661175

11671176
- [ ] **Step 1: Add fields to `RegisterPartnerRequest`**
@@ -1261,6 +1270,7 @@ git commit -m "Add first-party signal fields to partner registration endpoint"
12611270
### Task 10: Implement `write_fp_signals` with tests
12621271

12631272
**Files:**
1273+
12641274
- Modify: `crates/trusted-server-core/src/ec/fp_signals.rs`
12651275

12661276
- [ ] **Step 1: Write batched write tests**
@@ -1628,6 +1638,7 @@ git commit -m "Implement batched CAS write for first-party signals"
16281638
### Task 11: Wire extraction and writing into the adapter
16291639

16301640
**Files:**
1641+
16311642
- Modify: `crates/trusted-server-adapter-fastly/src/main.rs`
16321643

16331644
- [ ] **Step 1: Update imports**

0 commit comments

Comments
 (0)