Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Added

- New `generic_alert` APNs payload mode: a visible alert whose title and body come from the new `apns.alert_title` / `apns.alert_body` configuration keys (content-free defaults), with no `mutable-content` flag, so deployments can show a notification without shipping a Notification Service Extension. An optional `apns.collapse_id` key sets the `apns-collapse-id` header (any payload mode) so rapid bursts coalesce into a single notification.
- Optional error and panic reporting to a GlitchTip (Sentry-compatible) instance, enabled by setting `glitchtip.dsn` (or `TRANSPONDER_GLITCHTIP_DSN`). Only Transponder's own `ERROR` events and panics are sent, over a self-contained TLS transport (bundled roots, no system CA store dependency); dependency-crate errors and lower log levels are dropped, and Transponder never logs or panics with secret material.

### Fixed
Expand Down
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,21 @@ environment = "production"
# Your iOS app's bundle identifier (e.g., "com.example.myapp")
bundle_id = ""

# APNs payload mode: "silent" or "nse_prototype_alert"
# Keep production silent. Use nse_prototype_alert only for staging NSE prototype testing.
# APNs payload mode: "silent", "generic_alert", or "nse_prototype_alert"
# - silent: background push only (invisible without an NSE or foreground work)
# - generic_alert: visible alert with the configurable, content-free
# alert_title/alert_body below; requires no Notification Service Extension
# - nse_prototype_alert: staging NSE prototype testing only
payload_mode = "silent"

# Alert copy for the generic_alert payload mode (keep it content-free)
alert_title = "New activity"
alert_body = "You have a new notification"

# Optional apns-collapse-id header value (max 64 bytes); empty disables it.
# When set, APNs coalesces undelivered notifications with the same id.
collapse_id = ""

[fcm]
# Enable FCM for Android push notifications
enabled = false
Expand Down
19 changes: 17 additions & 2 deletions config/default.toml
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,25 @@ environment = "production"
# iOS app bundle identifier
bundle_id = ""

# APNs payload mode: "silent" or "nse_prototype_alert"
# Keep production silent. Use nse_prototype_alert only for staging NSE prototype testing.
# APNs payload mode: "silent", "generic_alert", or "nse_prototype_alert"
# - silent: background push only; invisible without a Notification Service
# Extension or foreground app work.
# - generic_alert: visible alert with the configurable, content-free
# alert_title/alert_body below; requires no Notification Service Extension.
# - nse_prototype_alert: staging NSE prototype testing only.
payload_mode = "silent"

# Alert copy for the generic_alert payload mode. Keep these content-free: the
# transponder never knows message content or sender identity, and the alert
# must not pretend otherwise.
alert_title = "New activity"
alert_body = "You have a new notification"

# Optional apns-collapse-id header value (max 64 bytes); empty disables it.
# When set, APNs coalesces undelivered notifications with the same id, so a
# rapid burst surfaces as one notification instead of a stack.
collapse_id = ""

[fcm]
# Whether FCM (Android) push notifications are enabled
enabled = false
Expand Down
7 changes: 7 additions & 0 deletions config/production.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,14 @@ team_id = ""
private_key_path = "/credentials/AuthKey_XXXXXXXXXX.p8"
environment = "production"
bundle_id = ""
# "silent", "generic_alert" (visible content-free alert, no NSE required),
# or "nse_prototype_alert" (staging NSE prototype testing only).
payload_mode = "silent"
# Alert copy for the generic_alert payload mode (keep it content-free).
alert_title = "New activity"
alert_body = "You have a new notification"
# Optional apns-collapse-id header value (max 64 bytes); empty disables it.
collapse_id = ""

[fcm]
enabled = false
Expand Down
159 changes: 156 additions & 3 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,10 @@ fn default_max_reconnect_attempts() -> u32 {
10
}

/// Maximum length in bytes APNs accepts for the `apns-collapse-id` header;
/// longer values are rejected per-send with a 400 `BadCollapseId`.
const APNS_COLLAPSE_ID_MAX_BYTES: usize = 64;

/// APNs push notification configuration.
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
Expand Down Expand Up @@ -409,6 +413,22 @@ pub struct ApnsConfig {
/// APNs payload mode.
#[serde(default)]
pub payload_mode: ApnsPayloadMode,

/// Alert title for the `generic_alert` payload mode.
#[serde(default = "default_apns_alert_title")]
pub alert_title: String,

/// Alert body for the `generic_alert` payload mode.
#[serde(default = "default_apns_alert_body")]
pub alert_body: String,

/// Optional `apns-collapse-id` header value; empty disables the header.
///
/// When set, APNs coalesces undelivered notifications carrying the same
/// collapse identifier, so a rapid burst surfaces as a single notification
/// instead of a stack.
#[serde(default)]
pub collapse_id: String,
}

/// APNs gateway environment.
Expand Down Expand Up @@ -448,20 +468,26 @@ pub enum ApnsPayloadMode {

/// Alert payload for exercising the iOS Notification Service Extension prototype.
NsePrototypeAlert,

/// Visible alert with configurable, content-free title and body
/// (`apns.alert_title` / `apns.alert_body`). Requires no Notification
/// Service Extension: the payload carries no `mutable-content` flag and
/// never reveals message content or sender identity.
GenericAlert,
}

impl ApnsPayloadMode {
pub(crate) fn push_type(self) -> &'static str {
match self {
Self::Silent => "background",
Self::NsePrototypeAlert => "alert",
Self::NsePrototypeAlert | Self::GenericAlert => "alert",
}
}

pub(crate) fn priority(self) -> &'static str {
match self {
Self::Silent => "5",
Self::NsePrototypeAlert => "10",
Self::NsePrototypeAlert | Self::GenericAlert => "10",
}
}
}
Expand All @@ -471,6 +497,7 @@ impl std::fmt::Display for ApnsPayloadMode {
match self {
Self::Silent => f.write_str("silent"),
Self::NsePrototypeAlert => f.write_str("nse_prototype_alert"),
Self::GenericAlert => f.write_str("generic_alert"),
}
}
}
Expand All @@ -479,6 +506,14 @@ fn default_apns_environment() -> ApnsEnvironment {
ApnsEnvironment::Production
}

fn default_apns_alert_title() -> String {
"New activity".to_string()
}

fn default_apns_alert_body() -> String {
"You have a new notification".to_string()
}

/// FCM push notification configuration.
#[derive(Debug, Clone, Deserialize)]
pub struct FcmConfig {
Expand Down Expand Up @@ -897,6 +932,9 @@ fn is_supported_config_key(config_key: &str) -> bool {
| "apns.environment"
| "apns.bundle_id"
| "apns.payload_mode"
| "apns.alert_title"
| "apns.alert_body"
| "apns.collapse_id"
| "fcm.enabled"
| "fcm.service_account_path"
| "fcm.project_id"
Expand Down Expand Up @@ -1225,7 +1263,9 @@ impl GlitchtipConfig {
}

impl ApnsConfig {
/// Rejects enabled APNs configuration that is missing required credentials.
/// Rejects enabled APNs configuration that is missing required credentials,
/// a `generic_alert` mode with nothing to display, or an over-length
/// collapse identifier.
fn validate(&self) -> std::result::Result<(), config::ConfigError> {
if !self.enabled {
return Ok(());
Expand All @@ -1246,6 +1286,26 @@ impl ApnsConfig {
}
}

// A generic alert with neither a title nor a body renders nothing
// visible on the device, defeating the purpose of the mode.
if self.payload_mode == ApnsPayloadMode::GenericAlert
&& self.alert_title.trim().is_empty()
&& self.alert_body.trim().is_empty()
{
return Err(config::ConfigError::Message(
"apns.alert_title or apns.alert_body must be set when apns.payload_mode = \"generic_alert\"".to_string(),
));
}

// APNs rejects `apns-collapse-id` values over 64 bytes with a 400
// BadCollapseId on every send; fail at startup instead.
if self.collapse_id.len() > APNS_COLLAPSE_ID_MAX_BYTES {
return Err(config::ConfigError::Message(format!(
"apns.collapse_id must be at most {APNS_COLLAPSE_ID_MAX_BYTES} bytes, got {}",
self.collapse_id.len()
)));
}
Comment on lines +1300 to +1307

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does http crate HeaderValue::from_str reject carriage return and line feed characters?

💡 Result:

Yes, the http crate's HeaderValue::from_str method rejects carriage return (CR, ASCII 13) and line feed (LF, ASCII 10) characters [1][2]. The method validates input to ensure it contains only visible ASCII characters, which are defined as the range 32–127 [3][1]. Since both carriage return (13) and line feed (10) fall outside this permitted range of visible ASCII characters, they are considered invalid, and attempting to create a HeaderValue containing them via from_str will return an error [3][1]. The official documentation explicitly includes examples showing that passing a string with a newline character results in an error [1][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the relevant config and request-building code.
git ls-files | rg '^(src/config\.rs|src/.*apns.*\.rs|src/.*request.*\.rs|src/.*http.*\.rs)$'

# Show the nearby config validation block.
printf '\n--- src/config.rs (around collapse_id validation) ---\n'
sed -n '1280,1335p' src/config.rs

# Find where apns-collapse-id is attached to requests.
printf '\n--- search for apns-collapse-id / collapse_id usage ---\n'
rg -n 'apns-collapse-id|collapse_id|HeaderValue::from_str|header\(' src

# If a likely builder is found, inspect its definition with line numbers.
printf '\n--- candidate build_request / ApnsRequestParts definitions ---\n'
rg -n 'build_request|ApnsRequestParts' src

Repository: marmot-protocol/transponder

Length of output: 14679


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the APNs request builder and tests around collapse_id handling.
sed -n '430,490p' src/push/apns.rs

printf '\n--- surrounding tests ---\n'
sed -n '930,990p' src/push/apns.rs

# Read the config tests that already cover the max-length validation.
printf '\n--- config tests ---\n'
sed -n '2128,2160p' src/config.rs

# Probe whether any in-repo code treats invalid header values as request-build errors.
rg -n 'build_request\(|RequestBuilder|header\("apns-collapse-id"|from_str\(&self\.collapse_id\)' src/push/apns.rs src/config.rs

Repository: marmot-protocol/transponder

Length of output: 6356


🌐 Web query:

reqwest RequestBuilder header invalid HeaderValue returns error build send semantics

💡 Result:

In the reqwest library, the RequestBuilder maintains an internal Result state to handle configuration errors, including those that occur when adding invalid headers [1]. When you call the.header method on a RequestBuilder, it attempts to convert the provided key and value into a HeaderName and HeaderValue using TryFrom [2][1]. If the value provided is invalid (for example, containing restricted characters like CR, LF, or NUL [3]), this conversion returns an InvalidHeaderValue error [4][5]. Because RequestBuilder uses an internal mechanism to accumulate errors, providing an invalid header does not cause an immediate panic or return an error at the exact line where.header is called [1]. Instead, the error is stored within the RequestBuilder [1]. The error is surfaced only when you attempt to finalize or execute the request: 1. build: When you call.build, it consumes the RequestBuilder and returns a Result<Request, Error>. If a previous header configuration failed, this result will contain the error [1]. 2. send: When you call.send, it internally calls build. If the build process fails due to a stored error (such as an InvalidHeaderValue), the send method will return an error, preventing the request from being dispatched [1]. In summary, invalid header values do not interrupt the builder chain immediately; they "poison" the builder's internal state, and the resulting error is surfaced during the final build or send operation [1].

Citations:


Validate apns.collapse_id as a header value too

The length guard is good, but collapse_id is later passed to reqwest::RequestBuilder::header(). If it contains CR/LF or another invalid byte, the builder becomes invalid and build()/send() returns an Err for every APNs notification. Rejecting invalid header values here would fail fast at startup, just like the other config checks.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/config.rs` around lines 1300 - 1307, Extend the startup validation for
self.collapse_id alongside the existing length check to reject values that
cannot be used as HTTP header values, including CR/LF and other invalid bytes.
Return a config::ConfigError::Message with a clear validation error, preserving
the existing maximum-byte-length validation and fail-fast behavior.


Ok(())
}

Expand Down Expand Up @@ -1426,6 +1486,9 @@ mod tests {
environment: ApnsEnvironment::Production,
bundle_id: "com.example.app".to_string(),
payload_mode: Default::default(),
alert_title: String::new(),
alert_body: String::new(),
collapse_id: String::new(),
};

assert!(config.is_production());
Expand All @@ -1442,6 +1505,9 @@ mod tests {
environment: ApnsEnvironment::Sandbox,
bundle_id: String::new(),
payload_mode: Default::default(),
alert_title: String::new(),
alert_body: String::new(),
collapse_id: String::new(),
};

assert!(!config.is_production());
Expand Down Expand Up @@ -1963,6 +2029,8 @@ mod tests {
#[test]
fn test_apns_config_defaults() {
assert_eq!(default_apns_environment(), ApnsEnvironment::Production);
assert_eq!(default_apns_alert_title(), "New activity");
assert_eq!(default_apns_alert_body(), "You have a new notification");
}

#[test]
Expand Down Expand Up @@ -2005,6 +2073,85 @@ mod tests {
assert!(error.to_string().contains("loud_plaintext"), "{error}");
}

#[test]
fn test_apns_payload_mode_parses_generic_alert_with_default_copy() {
let config_content = r#"
[server]
private_key = "test"

[apns]
payload_mode = "generic_alert"
"#;

let file = create_temp_config(config_content);
let config = load_with_test_env(file.path(), &[]).unwrap();

assert_eq!(config.apns.payload_mode, ApnsPayloadMode::GenericAlert);
assert_eq!(config.apns.alert_title, "New activity");
assert_eq!(config.apns.alert_body, "You have a new notification");
assert_eq!(config.apns.collapse_id, "");
}

#[test]
fn test_apns_generic_alert_custom_copy_and_collapse_id() {
let config_content = r#"
[server]
private_key = "test"

[apns]
payload_mode = "generic_alert"
alert_title = "Ping"
alert_body = "Something happened"
collapse_id = "ping"
"#;

let file = create_temp_config(config_content);
let config = load_with_test_env(file.path(), &[]).unwrap();

assert_eq!(config.apns.alert_title, "Ping");
assert_eq!(config.apns.alert_body, "Something happened");
assert_eq!(config.apns.collapse_id, "ping");
}

#[test]
fn test_apns_generic_alert_without_title_or_body_rejected() {
// A generic alert with neither a title nor a body renders nothing
// visible, so an explicit double-blank is a startup error.
let error = from_test_env(&[
("TRANSPONDER_APNS_ENABLED", "true"),
("TRANSPONDER_APNS_KEY_ID", "KEY123"),
("TRANSPONDER_APNS_TEAM_ID", "TEAM123"),
("TRANSPONDER_APNS_PRIVATE_KEY_PATH", "/keys/apns.p8"),
("TRANSPONDER_APNS_BUNDLE_ID", "com.example.app"),
("TRANSPONDER_APNS_PAYLOAD_MODE", "generic_alert"),
("TRANSPONDER_APNS_ALERT_TITLE", ""),
("TRANSPONDER_APNS_ALERT_BODY", " "),
])
.unwrap_err();

let message = error.to_string();
assert!(message.contains("apns.alert_title"), "{message}");
assert!(message.contains("generic_alert"), "{message}");
}

#[test]
fn test_apns_collapse_id_over_max_length_rejected() {
// APNs rejects over-length collapse ids per-send (400 BadCollapseId);
// the misconfiguration must fail loudly at startup instead.
let long_collapse_id = "x".repeat(APNS_COLLAPSE_ID_MAX_BYTES + 1);
let error = from_test_env(&[
("TRANSPONDER_APNS_ENABLED", "true"),
("TRANSPONDER_APNS_KEY_ID", "KEY123"),
("TRANSPONDER_APNS_TEAM_ID", "TEAM123"),
("TRANSPONDER_APNS_PRIVATE_KEY_PATH", "/keys/apns.p8"),
("TRANSPONDER_APNS_BUNDLE_ID", "com.example.app"),
("TRANSPONDER_APNS_COLLAPSE_ID", long_collapse_id.as_str()),
])
.unwrap_err();

assert!(error.to_string().contains("apns.collapse_id"), "{error}");
}

#[test]
fn test_apns_environment_display() {
assert_eq!(ApnsEnvironment::Production.to_string(), "production");
Expand Down Expand Up @@ -2204,6 +2351,9 @@ mod tests {
environment: ApnsEnvironment::Production,
bundle_id: String::new(),
payload_mode: Default::default(),
alert_title: String::new(),
alert_body: String::new(),
collapse_id: String::new(),
};
assert!(config.is_production());
}
Expand All @@ -2218,6 +2368,9 @@ mod tests {
environment: ApnsEnvironment::Sandbox,
bundle_id: String::new(),
payload_mode: Default::default(),
alert_title: String::new(),
alert_body: String::new(),
collapse_id: String::new(),
};
assert!(!config.is_production());
}
Expand Down
9 changes: 9 additions & 0 deletions src/nostr/events/processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1224,6 +1224,9 @@ mod tests {
environment: crate::config::ApnsEnvironment::Sandbox,
bundle_id: "com.example.app".to_string(),
payload_mode: Default::default(),
alert_title: String::new(),
alert_body: String::new(),
collapse_id: String::new(),
};
let metrics = Metrics::new().expect("metrics");
let push_dispatcher = Arc::new(PushDispatcher::with_metrics(
Expand Down Expand Up @@ -1267,6 +1270,9 @@ mod tests {
environment: crate::config::ApnsEnvironment::Sandbox,
bundle_id: "com.example.app".to_string(),
payload_mode: Default::default(),
alert_title: String::new(),
alert_body: String::new(),
collapse_id: String::new(),
};
let metrics = Metrics::new().expect("metrics");
let push_dispatcher = Arc::new(PushDispatcher::with_metrics(
Expand Down Expand Up @@ -2526,6 +2532,9 @@ mod tests {
environment: crate::config::ApnsEnvironment::Sandbox,
bundle_id: "com.example.app".to_string(),
payload_mode: Default::default(),
alert_title: String::new(),
alert_body: String::new(),
collapse_id: String::new(),
};
let push_dispatcher = Arc::new(PushDispatcher::with_metrics(
Some(ApnsClient::mock(apns_config, true)),
Expand Down
Loading