diff --git a/.cursor/rules/typescript-coding-guidelines.mdc b/.cursor/rules/typescript-coding-guidelines.mdc deleted file mode 100644 index 44d73e7..0000000 --- a/.cursor/rules/typescript-coding-guidelines.mdc +++ /dev/null @@ -1,79 +0,0 @@ ---- -description: TypeScript coding guidelines / style – check when working on TypeScript files -globs: *.ts, *.tsx -alwaysApply: false ---- -# General - -- Prefer 'const myFunc = () => {}' over function declarations. -- Parallelize async calls where appropriate via `Promise.all` instead of sequential fetches in a `for` loop. -- No `any` allowed. If the input type is genuinely unknown, use `unknown` and write appropriate logic to narrow the type. -- Prefer `??` over `||` -- No unchecked index access (`array[0]` might be `undefined`). Optional chain or check and throw and error (depending on whether it is acceptable for the result to be undefined or not). -- Don't make formatting corrections to lines you aren't already modifying. Auto-fixing will handle it. -- Avoid single-letter variable names. e.g. `event`, not `e`. Exception: in `for` loops (e.g. `i` is fine). -- Variable names are all in `camelCase`. No `SCREAMING_SNAKE_CASE`. -- Check function and type signatures carefully to understand APIs and what fields are available. - For external libraries, these should be available in a type declaration file. - Don't guess APIs! Check the signatures. - -# Avoid these! - -- Don't make changes unrelated to the user's requests. You can suggest further changes at the end of your work. -- Don't get stuck fixing linting errors – stop and ask for help sooner. -- Don't attempt to run the app. Let the user handle testing. - -# Workspaces - -- The project uses yarn workspaces. -- If you want to install a dependency, you need to do it in the relevant workspace. e.g. `hash/apps/hash-frontend`. -- The project - -# Frontend - -- Use React function components -- Use MUI components for all HTML elements (import from `@mui/material`) - - Style using the `sx` prop - - Flex boxes should use the `Stack` component -- Use ApolloClient hooks for fetching from the API (e.g. `useQuery`) - -# Entities - -- An `Entity` is a key primitive in the system. They have types which describe their properties. Each entity can have multiple types. - -## Types - -- A `ClosedMultiEntityType` is the combined schema of the entity types an entity has. -- When requesting entities from the GraphQL API, via `getEntitySubgraphQuery`, a map of `ClosedMultiEntityType`s is available on - `closedMultiEntityTypes` in the return field (so long as `includeEntityTypes: "resolved"` has been passed in the `variables.request` object) -- To retrieve a specific `ClosedMultiEntityType` from the map, use `getClosedMultiEntityTypeFromMap` imported from `@local/hash-graph-sdk/entity` -- The property and data types for a given `ClosedMultiEntityType` are available in the `definitions` field returned from the API. -- Labels for entities (human-readable display strings) can be generated by calling `generateEntityLabel(closedMultiEntityType, entity)` -- If a `ClosedMultiEntityType` is needed for an arbitrary set of `entityTypeIds`, e.g. for draft/proposed entities, use `getClosedMultiEntityTypesQuery`. - It also returns a `closedMultiEntityTypes` and `definitions` fields. - This should be an array of arrays, where each entry in the array is a set of entityTypeIds you require a closed type for. -- To get the `icon` for an entity if required, call `getDisplayFieldsForClosedEntityType(closedMultiEntityType)`. - -## Linked entities - -- A 'subgraph' will contain details of entities and entities linked to it. - -# File organization - -- When exports from a module are only used in one other file, they should be created in a subfolder named after the importing file. - e.g. - -```md -- page.tsx -- page/header.tsx -- page/header/header-button.tsx -- page/sidebar.tsx -``` - -- If a module is used by multiple other files, it should be in a `shared` folder at the highest point at which it is needed in the file tree. e.g. - -```md -- page.tsx -- api.tsx -- shared/types.ts // types is used by both page and api -``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b17511..57e7085 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added basic unit tests - Added publisher config +- Add AI assist rules. Based on https://github.com/hashintel/hash ### Changed - Upgrade to rust 1.87.0 @@ -38,6 +39,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Changed to use constants for headers - Changed to use log statements - Updated fastly.toml for local development +- Changed to propagate server errors as HTTP errors + +### Fixed +- Rebuild when `TRUSTED_SERVER__*` env variables change ### Fixed - Rebuild when `TRUSTED_SERVER__*` env variables change diff --git a/CLAUDE.md b/CLAUDE.md index 3484430..8b71257 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,7 @@ cargo check ### Configuration Files - `fastly.toml`: Fastly service configuration and build settings - `trusted-server.toml`: Application settings (ad servers, KV stores, ID templates) -- `rust-toolchain.toml`: Pins Rust version to 1.83.0 +- `rust-toolchain.toml`: Pins Rust version to 1.87.0 ### Key Functionality Areas 1. **Synthetic ID Generation**: Privacy-preserving user identification using HMAC diff --git a/Cargo.lock b/Cargo.lock index 14603d0..c929cb4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -330,6 +330,27 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "derive_more" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", + "unicode-xid", +] + [[package]] name = "digest" version = "0.9.0" @@ -407,6 +428,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "error-stack" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe413319145d1063f080f27556fd30b1d70b01e2ba10c2a6e40d4be982ffc5d1" +dependencies = [ + "anyhow", + "rustc_version", +] + [[package]] name = "fastly" version = "0.11.5" @@ -1193,6 +1224,15 @@ version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustversion" version = "1.0.21" @@ -1211,6 +1251,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" + [[package]] name = "serde" version = "1.0.219" @@ -1535,6 +1581,8 @@ dependencies = [ "chrono", "config", "cookie", + "derive_more", + "error-stack", "fastly", "futures", "handlebars", @@ -1556,6 +1604,7 @@ dependencies = [ name = "trusted-server-fastly" version = "0.1.0" dependencies = [ + "error-stack", "fastly", "futures", "log", @@ -1589,6 +1638,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "url" version = "2.5.4" diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 01eee86..2cae701 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -12,6 +12,8 @@ license = "Apache-2.0" chrono = "0.4" config = "0.15.11" cookie = "0.18.1" +derive_more = { version = "1.0", features = ["display", "error"] } +error-stack = "0.5" fastly = "0.11.5" futures = "0.3" handlebars = "6.3.2" @@ -30,6 +32,9 @@ url = "2.4.1" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.91" config = "0.15.11" +derive_more = { version = "1.0", features = ["display", "error"] } +error-stack = "0.5" +http = "1.3.1" [dev-dependencies] regex = "1.1.1" diff --git a/crates/common/build.rs b/crates/common/build.rs index f25ec0d..24b9108 100644 --- a/crates/common/build.rs +++ b/crates/common/build.rs @@ -1,3 +1,6 @@ +#[path = "src/error.rs"] +mod error; + #[path = "src/settings.rs"] mod settings; diff --git a/crates/common/src/cookies.rs b/crates/common/src/cookies.rs index b4494d8..de78b10 100644 --- a/crates/common/src/cookies.rs +++ b/crates/common/src/cookies.rs @@ -1,12 +1,22 @@ +//! Cookie handling utilities. +//! +//! This module provides functionality for parsing and creating cookies +//! used in the trusted server system. + use cookie::{Cookie, CookieJar}; +use error_stack::{Report, ResultExt}; use fastly::http::header; use fastly::Request; +use crate::error::TrustedServerError; use crate::settings::Settings; const COOKIE_MAX_AGE: i32 = 365 * 24 * 60 * 60; // 1 year -// return empty cookie jar for unparsable cookies +/// Parses a cookie string into a [`CookieJar`]. +/// +/// Returns an empty jar if the cookie string is unparseable. +/// Individual invalid cookies are skipped rather than failing the entire parse. pub fn parse_cookies_to_jar(s: &str) -> CookieJar { let cookie_str = s.trim().to_owned(); let mut jar = CookieJar::new(); @@ -19,20 +29,39 @@ pub fn parse_cookies_to_jar(s: &str) -> CookieJar { jar } -pub fn handle_request_cookies(req: &Request) -> Option { +/// Extracts and parses cookies from an HTTP request. +/// +/// Attempts to parse the Cookie header into a [`CookieJar`] for easy access +/// to individual cookies. +/// +/// # Errors +/// +/// - [`TrustedServerError::InvalidHeaderValue`] if the Cookie header contains invalid UTF-8 +pub fn handle_request_cookies( + req: &Request, +) -> Result, Report> { match req.get_header(header::COOKIE) { Some(header_value) => { - let header_value_str: &str = header_value.to_str().unwrap_or(""); - let jar: CookieJar = parse_cookies_to_jar(header_value_str); - Some(jar) + let header_value_str = + header_value + .to_str() + .change_context(TrustedServerError::InvalidHeaderValue { + message: "Cookie header contains invalid UTF-8".to_string(), + })?; + let jar = parse_cookies_to_jar(header_value_str); + Ok(Some(jar)) } None => { - log::warn!("No cookie header found in request"); - None + log::debug!("No cookie header found in request"); + Ok(None) } } } +/// Creates a synthetic ID cookie string. +/// +/// Generates a properly formatted cookie with security attributes +/// for storing the synthetic ID. pub fn create_synthetic_cookie(settings: &Settings, synthetic_id: &str) -> String { format!( "synthetic_id={}; Domain={}; Path=/; Secure; SameSite=Lax; Max-Age={}", @@ -84,7 +113,9 @@ mod tests { #[test] fn test_handle_request_cookies() { let req = Request::get("http://example.com").with_header(header::COOKIE, "c1=v1;c2=v2"); - let jar = handle_request_cookies(&req).unwrap(); + let jar = handle_request_cookies(&req) + .expect("should parse cookies") + .expect("should have cookie jar"); assert!(jar.iter().count() == 2); assert_eq!(jar.get("c1").unwrap().value(), "v1"); @@ -94,7 +125,9 @@ mod tests { #[test] fn test_handle_request_cookies_with_empty_cookie() { let req = Request::get("http://example.com").with_header(header::COOKIE, ""); - let jar = handle_request_cookies(&req).unwrap(); + let jar = handle_request_cookies(&req) + .expect("should parse cookies") + .expect("should have cookie jar"); assert!(jar.iter().count() == 0); } @@ -102,7 +135,7 @@ mod tests { #[test] fn test_handle_request_cookies_no_cookie_header() { let req: Request = Request::get("https://example.com"); - let jar = handle_request_cookies(&req); + let jar = handle_request_cookies(&req).expect("should handle missing cookie header"); assert!(jar.is_none()); } @@ -110,7 +143,9 @@ mod tests { #[test] fn test_handle_request_cookies_invalid_cookie_header() { let req = Request::get("http://example.com").with_header(header::COOKIE, "invalid"); - let jar = handle_request_cookies(&req).unwrap(); + let jar = handle_request_cookies(&req) + .expect("should parse cookies") + .expect("should have cookie jar"); assert!(jar.iter().count() == 0); } diff --git a/crates/common/src/error.rs b/crates/common/src/error.rs new file mode 100644 index 0000000..65a0c47 --- /dev/null +++ b/crates/common/src/error.rs @@ -0,0 +1,90 @@ +//! Error types for the trusted server. +//! +//! This module provides the main error type [`TrustedServerError`] used throughout +//! the application. All errors are designed to work with the `error-stack` crate +//! for rich error context and reporting. + +use core::error::Error; +use derive_more::Display; +use http::StatusCode; + +/// The main error type for trusted server operations. +/// +/// This enum encompasses all possible errors that can occur during +/// request processing, configuration, and data handling. +#[allow(dead_code)] +#[derive(Debug, Display)] +pub enum TrustedServerError { + /// Configuration errors that prevent the server from starting. + #[display("Configuration error: {message}")] + Configuration { message: String }, + + /// The synthetic secret key is using the insecure default value. + #[display("Synthetic secret key is set to the default value - this is insecure")] + InsecureSecretKey, + + /// Invalid UTF-8 data encountered. + #[display("Invalid UTF-8 data: {message}")] + InvalidUtf8 { message: String }, + + /// HTTP header value creation failed. + #[display("Invalid HTTP header value: {message}")] + InvalidHeaderValue { message: String }, + + /// Settings parsing or validation failed. + #[display("Settings error: {message}")] + Settings { message: String }, + + /// GDPR consent handling error. + #[display("GDPR consent error: {message}")] + GdprConsent { message: String }, + + /// Synthetic ID generation or validation failed. + #[display("Synthetic ID error: {message}")] + SyntheticId { message: String }, + + /// Prebid integration error. + #[display("Prebid error: {message}")] + Prebid { message: String }, + + /// Key-value store operation failed. + #[display("KV store error: {store_name} - {message}")] + KvStore { store_name: String, message: String }, + + /// Template rendering error. + #[display("Template error: {message}")] + Template { message: String }, +} + +impl Error for TrustedServerError {} + +/// Extension trait for converting [`TrustedServerError`] to HTTP responses. +#[allow(dead_code)] +pub trait IntoHttpResponse { + /// Convert the error into an HTTP status code. + fn status_code(&self) -> StatusCode; + + /// Get the error message to show to users (uses the Display implementation). + fn user_message(&self) -> String; +} + +impl IntoHttpResponse for TrustedServerError { + fn status_code(&self) -> StatusCode { + match self { + Self::Configuration { .. } | Self::Settings { .. } => StatusCode::INTERNAL_SERVER_ERROR, + Self::InsecureSecretKey => StatusCode::INTERNAL_SERVER_ERROR, + Self::InvalidUtf8 { .. } => StatusCode::BAD_REQUEST, + Self::InvalidHeaderValue { .. } => StatusCode::BAD_REQUEST, + Self::GdprConsent { .. } => StatusCode::BAD_REQUEST, + Self::SyntheticId { .. } => StatusCode::INTERNAL_SERVER_ERROR, + Self::Prebid { .. } => StatusCode::BAD_GATEWAY, + Self::KvStore { .. } => StatusCode::SERVICE_UNAVAILABLE, + Self::Template { .. } => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn user_message(&self) -> String { + // Use the Display implementation which already has the specific error message + self.to_string() + } +} diff --git a/crates/common/src/gdpr.rs b/crates/common/src/gdpr.rs index 48b3c51..f7f27d9 100644 --- a/crates/common/src/gdpr.rs +++ b/crates/common/src/gdpr.rs @@ -1,3 +1,8 @@ +//! GDPR consent management and compliance. +//! +//! This module provides functionality for managing GDPR consent, including +//! consent tracking, data subject requests, and compliance with EU privacy regulations. + use fastly::http::{header, Method, StatusCode}; use fastly::{Error, Request, Response}; use serde::{Deserialize, Serialize}; @@ -7,20 +12,36 @@ use crate::constants::HEADER_X_SUBJECT_ID; use crate::cookies; use crate::settings::Settings; +/// GDPR consent information for a user. +/// +/// Tracks consent status for different purposes as required by GDPR. #[derive(Debug, Serialize, Deserialize, Clone)] pub struct GdprConsent { + /// Consent for analytics and measurement. pub analytics: bool, + /// Consent for personalized advertising. pub advertising: bool, + /// Consent for functional cookies and features. pub functional: bool, + /// Unix timestamp when consent was given. pub timestamp: i64, + /// Version of the consent framework. pub version: String, } +/// User data collected for GDPR compliance. +/// +/// Contains all data collected about a user that must be made available +/// for data subject access requests. #[derive(Debug, Serialize, Deserialize)] pub struct UserData { + /// Number of visits by the user. pub visit_count: i32, + /// Unix timestamp of the last visit. pub last_visit: i64, + /// List of ad interaction events. pub ad_interactions: Vec, + /// History of consent changes. pub consent_history: Vec, } @@ -47,17 +68,34 @@ impl Default for UserData { } } +/// Extracts GDPR consent information from a request. +/// +/// Looks for consent information in the `gdpr_consent` cookie and parses +/// it into a [`GdprConsent`] structure. +/// +/// Returns [`None`] if no consent cookie is found or parsing fails. pub fn get_consent_from_request(req: &Request) -> Option { - if let Some(jar) = cookies::handle_request_cookies(req) { - if let Some(consent_cookie) = jar.get("gdpr_consent") { - if let Ok(consent) = serde_json::from_str(consent_cookie.value()) { - return Some(consent); + match cookies::handle_request_cookies(req) { + Ok(Some(jar)) => { + if let Some(consent_cookie) = jar.get("gdpr_consent") { + if let Ok(consent) = serde_json::from_str(consent_cookie.value()) { + return Some(consent); + } } + None + } + Ok(None) => None, + Err(e) => { + log::warn!("Failed to parse cookies for consent: {:?}", e); + None } } - None } +/// Creates a GDPR consent cookie string. +/// +/// Generates a properly formatted cookie string with the consent data, +/// including security attributes and domain settings. pub fn create_consent_cookie(settings: &Settings, consent: &GdprConsent) -> String { format!( "gdpr_consent={}; Domain={}; Path=/; Secure; SameSite=Lax; Max-Age=31536000", @@ -66,6 +104,15 @@ pub fn create_consent_cookie(settings: &Settings, consent: &GdprConsent) -> Stri ) } +/// Handles GDPR consent management requests. +/// +/// Processes GET and POST requests to the `/gdpr/consent` endpoint: +/// - GET: Returns current consent status +/// - POST: Updates consent preferences +/// +/// # Errors +/// +/// Returns a Fastly [`Error`] if response creation fails. pub fn handle_consent_request(settings: &Settings, req: Request) -> Result { match *req.get_method() { Method::GET => { @@ -95,6 +142,17 @@ pub fn handle_consent_request(settings: &Settings, req: Request) -> Result Result { match *req.get_method() { Method::GET => { diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 3fde24a..d425c2c 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -1,5 +1,26 @@ +//! Common functionality for the trusted server. +//! +//! This crate provides shared types, utilities, and abstractions used by both +//! the Fastly edge implementation and local development/testing environments. +//! +//! # Modules +//! +//! - [`constants`]: Application-wide constants and configuration values +//! - [`cookies`]: Cookie parsing and generation utilities +//! - [`error`]: Error types and error handling utilities +//! - [`gdpr`]: GDPR consent management and TCF string parsing +//! - [`models`]: Data models for ad serving and callbacks +//! - [`prebid`]: Prebid integration and real-time bidding support +//! - [`privacy`]: Privacy utilities and helpers +//! - [`settings`]: Configuration management and validation +//! - [`synthetic`]: Synthetic ID generation using HMAC +//! - [`templates`]: Handlebars template handling +//! - [`test_support`]: Testing utilities and mocks +//! - [`why`]: Debugging and introspection utilities + pub mod constants; pub mod cookies; +pub mod error; pub mod gdpr; pub mod models; pub mod prebid; diff --git a/crates/common/src/models.rs b/crates/common/src/models.rs index 9b14a07..7789b16 100644 --- a/crates/common/src/models.rs +++ b/crates/common/src/models.rs @@ -1,26 +1,51 @@ +//! Data models for ad serving and callbacks. +//! +//! This module defines the structures used for communication with ad servers +//! and tracking callbacks. + use serde::Deserialize; +/// Response from an ad server containing creative details. +/// +/// Contains all the information needed to display an ad and track +/// its performance through various callbacks. #[allow(dead_code)] #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct AdResponse { + /// Network identifier for the ad network. pub network_id: String, + /// Site identifier where the ad will be displayed. pub site_id: String, + /// Page identifier within the site. pub page_id: String, + /// Format identifier for the ad format. pub format_id: String, + /// Advertiser identifier. pub advertiser_id: String, + /// Campaign identifier. pub campaign_id: String, + /// Insertion order identifier. pub insertion_id: String, + /// Creative identifier. pub creative_id: String, + /// URL of the creative asset to display. pub creative_url: String, + /// List of tracking callbacks for various events. pub callbacks: Vec, } +/// Tracking callback for ad events. +/// +/// Represents a URL that should be called when specific ad events occur, +/// such as impressions, clicks, or viewability milestones. #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct Callback { + /// Type of callback (e.g., "impression", "click", "viewable"). #[serde(rename = "type")] pub callback_type: String, + /// URL to call when the event occurs. pub url: String, } diff --git a/crates/common/src/prebid.rs b/crates/common/src/prebid.rs index d94a60c..b998986 100644 --- a/crates/common/src/prebid.rs +++ b/crates/common/src/prebid.rs @@ -1,3 +1,9 @@ +//! Prebid integration for real-time bidding. +//! +//! This module provides functionality for integrating with Prebid Server +//! to enable header bidding and real-time ad auctions. + +use error_stack::Report; use fastly::http::{header, Method}; use fastly::{Error, Request, Response}; use serde_json::json; @@ -5,6 +11,7 @@ use serde_json::json; use crate::constants::{ HEADER_SYNTHETIC_FRESH, HEADER_SYNTHETIC_TRUSTED_SERVER, HEADER_X_FORWARDED_FOR, }; +use crate::error::TrustedServerError; use crate::settings::Settings; use crate::synthetic::generate_synthetic_id; @@ -23,20 +30,24 @@ pub struct PrebidRequest { } impl PrebidRequest { - /// Creates a new PrebidRequest from an incoming Fastly request + /// Creates a new PrebidRequest from an incoming Fastly request. /// - /// # Arguments - /// * `req` - The incoming Fastly request + /// Extracts necessary information from the request including synthetic ID, + /// client IP, and origin for use in Prebid Server requests. /// - /// # Returns - /// * `Result` - New PrebidRequest or error - pub fn new(settings: &Settings, req: &Request) -> Result { + /// # Errors + /// + /// - [`TrustedServerError::SyntheticId`] if synthetic ID generation fails + pub fn new(settings: &Settings, req: &Request) -> Result> { // Get the Trusted Server ID from header (which we just set in handle_prebid_test) - let synthetic_id = req + let synthetic_id = match req .get_header(HEADER_SYNTHETIC_TRUSTED_SERVER) .and_then(|h| h.to_str().ok()) .map(|s| s.to_string()) - .unwrap_or_else(|| generate_synthetic_id(settings, req)); + { + Some(id) => id, + None => generate_synthetic_id(settings, req)?, + }; // Get the original client IP from Fastly headers let client_ip = req diff --git a/crates/common/src/settings.rs b/crates/common/src/settings.rs index 8bf69ba..05f372e 100644 --- a/crates/common/src/settings.rs +++ b/crates/common/src/settings.rs @@ -1,8 +1,11 @@ -use std::str; +use core::str; -use config::{Config, ConfigError, Environment, File, FileFormat}; +use config::{Config, Environment, File, FileFormat}; +use error_stack::{Report, ResultExt}; use serde::{Deserialize, Serialize}; +use crate::error::TrustedServerError; + pub const ENVIRONMENT_VARIABLE_PREFIX: &str = "TRUSTED_SERVER"; pub const ENVIRONMENT_VARIABLE_SEPARATOR: &str = "__"; @@ -42,14 +45,42 @@ pub struct Settings { #[allow(unused)] impl Settings { - pub fn new() -> Result { + /// Creates a new [`Settings`] instance from the embedded configuration file. + /// + /// Loads the configuration from the embedded `trusted-server.toml` file + /// and applies any environment variable overrides. + /// + /// # Errors + /// + /// - [`TrustedServerError::InvalidUtf8`] if the embedded TOML file contains invalid UTF-8 + /// - [`TrustedServerError::Configuration`] if the configuration is invalid or missing required fields + /// - [`TrustedServerError::InsecureSecretKey`] if the secret key is set to the default value + pub fn new() -> Result> { let toml_bytes = include_bytes!("../../../trusted-server.toml"); - let toml_str = str::from_utf8(toml_bytes).unwrap(); + let toml_str = + str::from_utf8(toml_bytes).change_context(TrustedServerError::InvalidUtf8 { + message: "embedded trusted-server.toml file".to_string(), + })?; + + let settings = Self::from_toml(toml_str)?; + + // Validate that the secret key is not the default + if settings.synthetic.secret_key == "secret-key" { + return Err(Report::new(TrustedServerError::InsecureSecretKey)); + } - Self::from_toml(toml_str) + Ok(settings) } - pub fn from_toml(toml_str: &str) -> Result { + /// Creates a new [`Settings`] instance from a TOML string. + /// + /// Parses the provided TOML configuration and applies any environment + /// variable overrides using the `TRUSTED_SERVER__` prefix. + /// + /// # Errors + /// + /// - [`TrustedServerError::Configuration`] if the TOML is invalid or missing required fields + pub fn from_toml(toml_str: &str) -> Result> { let environment = Environment::default() .prefix(ENVIRONMENT_VARIABLE_PREFIX) .separator(ENVIRONMENT_VARIABLE_SEPARATOR); @@ -58,10 +89,16 @@ impl Settings { let config = Config::builder() .add_source(toml) .add_source(environment) - .build()?; - + .build() + .change_context(TrustedServerError::Configuration { + message: "Failed to build configuration".to_string(), + })?; // You can deserialize (and thus freeze) the entire configuration as - config.try_deserialize() + config + .try_deserialize() + .change_context(TrustedServerError::Configuration { + message: "Failed to deserialize configuration".to_string(), + }) } } @@ -95,11 +132,11 @@ mod tests { #[test] fn test_settings_from_valid_toml() { let toml_str = crate_test_settings_str(); - let settings: Result = Settings::from_toml(&toml_str); + let settings = Settings::from_toml(&toml_str); assert!(settings.is_ok()); - let settings = settings.unwrap(); + let settings = settings.expect("should parse valid TOML"); assert_eq!( settings.ad_server.ad_partner_url, "https://test-adpartner.com" diff --git a/crates/common/src/synthetic.rs b/crates/common/src/synthetic.rs index 5d0f79f..22a7ef7 100644 --- a/crates/common/src/synthetic.rs +++ b/crates/common/src/synthetic.rs @@ -1,3 +1,9 @@ +//! Synthetic ID generation using HMAC. +//! +//! This module provides functionality for generating privacy-preserving synthetic IDs +//! based on various request parameters and a secret key. + +use error_stack::{Report, ResultExt}; use fastly::http::header; use fastly::Request; use handlebars::Handlebars; @@ -7,16 +13,28 @@ use sha2::Sha256; use crate::constants::{HEADER_SYNTHETIC_PUB_USER_ID, HEADER_SYNTHETIC_TRUSTED_SERVER}; use crate::cookies::handle_request_cookies; +use crate::error::TrustedServerError; use crate::settings::Settings; type HmacSha256 = Hmac; -/// Generates a fresh synthetic_id based on request parameters -pub fn generate_synthetic_id(settings: &Settings, req: &Request) -> String { +/// Generates a fresh synthetic ID based on request parameters. +/// +/// Creates a deterministic ID using HMAC-SHA256 with the configured secret key +/// and various request attributes including IP, user agent, cookies, and headers. +/// +/// # Errors +/// +/// - [`TrustedServerError::Template`] if the template rendering fails +/// - [`TrustedServerError::SyntheticId`] if HMAC generation fails +pub fn generate_synthetic_id( + settings: &Settings, + req: &Request, +) -> Result> { let user_agent = req .get_header(header::USER_AGENT) .map(|h| h.to_str().unwrap_or("unknown")); - let first_party_id = handle_request_cookies(req).and_then(|jar| { + let first_party_id = handle_request_cookies(req).ok().flatten().and_then(|jar| { jar.get("pub_userid") .map(|cookie| cookie.value().to_string()) }); @@ -44,21 +62,40 @@ pub fn generate_synthetic_id(settings: &Settings, req: &Request) -> String { let input_string = handlebars .render_template(&settings.synthetic.template, data) - .unwrap(); + .change_context(TrustedServerError::Template { + message: "Failed to render synthetic ID template".to_string(), + })?; + log::info!("Input string for fresh ID: {} {}", input_string, data); let mut mac = HmacSha256::new_from_slice(settings.synthetic.secret_key.as_bytes()) - .expect("HMAC can take key of any size"); + .change_context(TrustedServerError::SyntheticId { + message: "Failed to create HMAC instance".to_string(), + })?; mac.update(input_string.as_bytes()); let fresh_id = hex::encode(mac.finalize().into_bytes()); log::info!("Generated fresh ID: {}", fresh_id); - fresh_id + Ok(fresh_id) } -/// Gets or creates a synthetic_id from the request -pub fn get_or_generate_synthetic_id(settings: &Settings, req: &Request) -> String { +/// Gets or creates a synthetic ID from the request. +/// +/// Attempts to retrieve an existing synthetic ID from: +/// 1. The `X-Synthetic-Trusted-Server` header +/// 2. The `synthetic_id` cookie +/// +/// If neither exists, generates a new synthetic ID. +/// +/// # Errors +/// +/// - [`TrustedServerError::Template`] if template rendering fails during generation +/// - [`TrustedServerError::SyntheticId`] if ID generation fails +pub fn get_or_generate_synthetic_id( + settings: &Settings, + req: &Request, +) -> Result> { // First try to get existing Trusted Server ID from header if let Some(synthetic_id) = req .get_header(HEADER_SYNTHETIC_TRUSTED_SERVER) @@ -66,31 +103,30 @@ pub fn get_or_generate_synthetic_id(settings: &Settings, req: &Request) -> Strin .map(|s| s.to_string()) { log::info!("Using existing Synthetic ID from header: {}", synthetic_id); - return synthetic_id; + return Ok(synthetic_id); } - let req_cookie_jar: Option = handle_request_cookies(req); - match req_cookie_jar { + // Try to get synthetic ID from cookies + match handle_request_cookies(req)? { Some(jar) => { - let ts_cookie = jar.get("synthetic_id"); - if let Some(cookie) = ts_cookie { + if let Some(cookie) = jar.get("synthetic_id") { let id = cookie.value().to_string(); log::info!("Using existing Trusted Server ID from cookie: {}", id); - return id; + return Ok(id); } } None => { - log::warn!("No cookie header found in request"); + log::debug!("No cookie header found in request"); } } // If no existing Synthetic ID found, generate a fresh one - let fresh_id = generate_synthetic_id(settings, req); + let fresh_id = generate_synthetic_id(settings, req)?; log::info!( "No existing Synthetic ID found, using fresh ID: {}", fresh_id ); - fresh_id + Ok(fresh_id) } #[cfg(test)] @@ -104,7 +140,10 @@ mod tests { fn create_test_request(headers: Vec<(HeaderName, &str)>) -> Request { let mut req = Request::new("GET", "http://example.com"); for (key, value) in headers { - req.set_header(key, HeaderValue::from_str(value).unwrap()); + req.set_header( + key, + HeaderValue::from_str(value).expect("should create valid header value"), + ); } req @@ -121,7 +160,8 @@ mod tests { (header::ACCEPT_LANGUAGE, "en-US,en;q=0.9"), ]); - let synthetic_id = generate_synthetic_id(&settings, &req); + let synthetic_id = + generate_synthetic_id(&settings, &req).expect("should generate synthetic ID"); log::info!("Generated synthetic ID: {}", synthetic_id); assert_eq!( synthetic_id, @@ -137,7 +177,8 @@ mod tests { "existing_synthetic_id", )]); - let synthetic_id = get_or_generate_synthetic_id(&settings, &req); + let synthetic_id = get_or_generate_synthetic_id(&settings, &req) + .expect("should get or generate synthetic ID"); assert_eq!(synthetic_id, "existing_synthetic_id"); } @@ -146,7 +187,8 @@ mod tests { let settings = create_test_settings(); let req = create_test_request(vec![(header::COOKIE, "synthetic_id=existing_cookie_id")]); - let synthetic_id = get_or_generate_synthetic_id(&settings, &req); + let synthetic_id = get_or_generate_synthetic_id(&settings, &req) + .expect("should get or generate synthetic ID"); assert_eq!(synthetic_id, "existing_cookie_id"); } @@ -155,7 +197,8 @@ mod tests { let settings = create_test_settings(); let req = create_test_request(vec![]); - let synthetic_id = get_or_generate_synthetic_id(&settings, &req); + let synthetic_id = get_or_generate_synthetic_id(&settings, &req) + .expect("should get or generate synthetic ID"); assert!(!synthetic_id.is_empty()); } } diff --git a/crates/fastly/Cargo.toml b/crates/fastly/Cargo.toml index d60fdaf..d98e2b5 100644 --- a/crates/fastly/Cargo.toml +++ b/crates/fastly/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] +error-stack = "0.5" fastly = "0.11.5" futures = "0.3" log = "0.4.20" diff --git a/crates/fastly/src/error.rs b/crates/fastly/src/error.rs new file mode 100644 index 0000000..b5c19c3 --- /dev/null +++ b/crates/fastly/src/error.rs @@ -0,0 +1,19 @@ +//! Error conversion utilities for Fastly. +//! +//! This module provides conversions from [`TrustedServerError`] to HTTP responses. + +use error_stack::Report; +use fastly::Response; +use trusted_server_common::error::{IntoHttpResponse, TrustedServerError}; + +/// Converts a [`TrustedServerError`] into an HTTP error response. +pub fn to_error_response(report: Report) -> Response { + // Get the root error for status code and message + let root_error = report.current_context(); + + // Log the full error chain for debugging + log::error!("Error occurred: {:?}", report); + + Response::from_status(root_error.status_code()) + .with_body_text_plain(&format!("{}\n", root_error.user_message())) +} diff --git a/crates/fastly/src/main.rs b/crates/fastly/src/main.rs index 5ea5267..e32d593 100644 --- a/crates/fastly/src/main.rs +++ b/crates/fastly/src/main.rs @@ -1,10 +1,14 @@ +use std::env; + use fastly::geo::geo_lookup; use fastly::http::{header, Method, StatusCode}; use fastly::KVStore; use fastly::{Error, Request, Response}; use log::LevelFilter::Info; use serde_json::json; -use std::env; + +mod error; +use crate::error::to_error_response; use trusted_server_common::constants::{ HEADER_SYNTHETIC_FRESH, HEADER_SYNTHETIC_TRUSTED_SERVER, HEADER_X_COMPRESS_HINT, @@ -13,8 +17,9 @@ use trusted_server_common::constants::{ HEADER_X_GEO_INFO_AVAILABLE, HEADER_X_GEO_METRO_CODE, }; use trusted_server_common::cookies::create_synthetic_cookie; +// Note: TrustedServerError is used internally by the common crate use trusted_server_common::gdpr::{ - get_consent_from_request, handle_consent_request, handle_data_subject_request, + get_consent_from_request, handle_consent_request, handle_data_subject_request, GdprConsent, }; use trusted_server_common::models::AdResponse; use trusted_server_common::prebid::PrebidRequest; @@ -26,7 +31,13 @@ use trusted_server_common::why::WHY_TEMPLATE; #[fastly::main] fn main(req: Request) -> Result { - let settings = Settings::new().unwrap(); + let settings = match Settings::new() { + Ok(s) => s, + Err(e) => { + log::error!("Failed to load settings: {:?}", e); + return Ok(to_error_response(e)); + } + }; log::info!("Settings {settings:?}"); futures::executor::block_on(async { @@ -112,6 +123,13 @@ fn get_dma_code(req: &mut Request) -> Option { None } +/// Handles the main page request. +/// +/// Serves the main page with synthetic ID generation and ad integration. +/// +/// # Errors +/// +/// Returns a Fastly [`Error`] if response creation fails. fn handle_main_page(settings: &Settings, mut req: Request) -> Result { log::info!( "Using ad_partner_url: {}, counter_store: {}", @@ -126,7 +144,13 @@ fn handle_main_page(settings: &Settings, mut req: Request) -> Result c, + None => { + log::debug!("No GDPR consent found, using default"); + GdprConsent::default() + } + }; if !consent.functional { // Return a version of the page without tracking return Ok(Response::from_status(StatusCode::OK) @@ -138,26 +162,32 @@ fn handle_main_page(settings: &Settings, mut req: Request) -> Result id, + Err(e) => return Ok(to_error_response(e)), + }; // Check for existing Trusted Server ID in this specific order: // 1. X-Synthetic-Trusted-Server header // 2. Cookie // 3. Fall back to fresh ID - let synthetic_id = get_or_generate_synthetic_id(settings, &req); + let synthetic_id = match get_or_generate_synthetic_id(settings, &req) { + Ok(id) => id, + Err(e) => return Ok(to_error_response(e)), + }; log::info!( "Existing Trusted Server header: {:?}", req.get_header(HEADER_SYNTHETIC_TRUSTED_SERVER) ); - log::info!("Generated Fresh ID: {}", fresh_id); + log::info!("Generated Fresh ID: {}", &fresh_id); log::info!("Using Trusted Server ID: {}", synthetic_id); // Create response with the main page HTML let mut response = Response::from_status(StatusCode::OK) .with_body(HTML_TEMPLATE) .with_header(header::CONTENT_TYPE, "text/html") - .with_header(HEADER_SYNTHETIC_FRESH, &fresh_id) // Fresh ID always changes + .with_header(HEADER_SYNTHETIC_FRESH, fresh_id.as_str()) // Fresh ID always changes .with_header(HEADER_SYNTHETIC_TRUSTED_SERVER, &synthetic_id) // Trusted Server ID remains stable .with_header( header::ACCESS_CONTROL_EXPOSE_HEADERS, @@ -206,9 +236,22 @@ fn handle_main_page(settings: &Settings, mut req: Request) -> Result Result { // Check GDPR consent to determine if we should serve personalized or non-personalized ads - let _consent = get_consent_from_request(&req).unwrap_or_default(); + let _consent = match get_consent_from_request(&req) { + Some(c) => c, + None => { + log::debug!("No GDPR consent found in ad request, using default"); + GdprConsent::default() + } + }; let advertising_consent = req .get_header(HEADER_X_CONSENT_ADVERTISING) .and_then(|h| h.to_str().ok()) @@ -235,7 +278,10 @@ fn handle_ad_request(settings: &Settings, mut req: Request) -> Result id, + Err(e) => return Ok(to_error_response(e)), + } } else { // Use a generic ID for non-personalized ads "non-personalized".to_string() @@ -471,9 +517,21 @@ async fn handle_prebid_test(settings: &Settings, mut req: Request) -> Result (fresh, synth), + (Err(e), _) | (_, Err(e)) => { + log::error!("Failed to generate IDs: {:?}", e); + return Ok(Response::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_header(header::CONTENT_TYPE, "application/json") + .with_body_json(&json!({ + "error": "Failed to generate IDs", + "details": format!("{:?}", e) + }))?); + } + } } else { // Use non-personalized IDs when no consent ( @@ -486,7 +544,7 @@ async fn handle_prebid_test(settings: &Settings, mut req: Request) -> Result