Skip to content

Add configurable generic_alert APNs payload mode#307

Open
JeroenOnNostr wants to merge 1 commit into
marmot-protocol:masterfrom
JeroenOnNostr:feat/generic-alert-payload-mode
Open

Add configurable generic_alert APNs payload mode#307
JeroenOnNostr wants to merge 1 commit into
marmot-protocol:masterfrom
JeroenOnNostr:feat/generic-alert-payload-mode

Conversation

@JeroenOnNostr

@JeroenOnNostr JeroenOnNostr commented Jul 12, 2026

Copy link
Copy Markdown

Motivation

Deployments that want a visible iOS notification currently have to pick between the two existing payload_modes, and neither fits:

  • silent sends content-available only, which is invisible unless the app ships a Notification Service Extension or happens to be foregrounded.
  • nse_prototype_alert is visible, but carries hardcoded White Noise copy ("White Noise" / "New encrypted message"), sets mutable-content: 1 (so it really wants an NSE), and includes the wn_nse_prototype marker.

Some deployments would rather show a plain, content-free alert ("New activity") without shipping an NSE at all — the transponder is blind by design, so there is nothing app-specific the alert could say anyway.

What's added

A third APNs payload mode, generic_alert:

  • Configurable copy: new apns.alert_title / apns.alert_body keys (content-free defaults: "New activity" / "You have a new notification"). An empty value omits that key from the alert dictionary, supporting title-only or body-only alerts; configuring both empty is rejected at startup.
  • No NSE required: the payload is exactly {"aps":{"alert":{...},"sound":"default"}} — no mutable-content, no prototype marker.
  • Optional coalescing: a new apns.collapse_id key sets the apns-collapse-id header on every send (any payload mode), so a rapid burst of pushes surfaces as one notification instead of a stack. Values over APNs's 64-byte limit are rejected at startup instead of failing per-send with 400 BadCollapseId.

Both existing modes are byte-for-byte unchanged (their tests still assert the exact payloads), and generic_alert reuses the existing alert/10 push-type/priority pairing. New keys are wired through the TRANSPONDER_APNS_* env-override allowlist and documented in config/default.toml, config/production.toml.example, and the README.

Tests

  • test_generic_alert_mode_builds_alert_headers_and_payload — exact headers + exact payload JSON (proving no mutable-content and no collapse header when unset)
  • test_generic_alert_mode_omits_empty_body_from_alert — title-only alert
  • test_generic_alert_mode_sets_collapse_id_header_when_configured / test_collapse_id_header_applies_to_any_payload_mode
  • config parsing/validation: generic_alert parses with defaults, custom copy + collapse id, both-empty copy rejected, over-length collapse id rejected

cargo build, cargo test (717 passed), cargo fmt --check, and cargo clippy --all-targets are all clean.

🤖 Generated with Claude Code


Open in Stage

Summary by CodeRabbit

  • New Features

    • Added a generic_alert APNs notification mode with configurable alert titles and bodies.
    • Added optional APNs collapse IDs to combine rapid notification bursts.
    • Added configuration validation for required alert content and collapse ID length limits.
  • Documentation

    • Updated default, production, and README configuration examples with the new APNs options and supported modes.

Deployments that want a visible iOS notification currently have to choose
between silent (invisible without a Notification Service Extension) and
nse_prototype_alert (hardcoded White Noise copy plus mutable-content, which
also requires shipping an NSE). Add a third payload mode, generic_alert,
that shows a plain alert with operator-configured, content-free copy:

- apns.alert_title / apns.alert_body configure the alert text (content-free
  defaults); an empty value omits that key for title-only or body-only
  alerts, and configuring both empty is rejected at startup.
- The payload carries no mutable-content flag, so no Notification Service
  Extension is needed to display it, and no prototype marker field.
- A new optional apns.collapse_id key sets the apns-collapse-id header on
  every send (any payload mode), so rapid bursts coalesce into a single
  notification; values over the 64-byte APNs limit are rejected at startup
  instead of failing per-send with 400 BadCollapseId.
@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Adds the APNs generic_alert payload mode, configurable alert text, and optional collapse identifiers. Configuration validation, environment overrides, request headers, payload serialization, documentation, and related test fixtures are updated.

Changes

APNs configuration and validation

Layer / File(s) Summary
Configuration contract, validation, and fixtures
CHANGELOG.md, README.md, config/*.toml*, src/config.rs, src/nostr/events/processor.rs, src/push/dispatcher/mod.rs, src/server/health.rs
Adds generic_alert, alert_title, alert_body, and collapse_id; validates nonblank alert content and the 64-byte collapse-ID limit; updates environment overrides, documentation, tests, and ApnsConfig fixtures.

APNs delivery path

Layer / File(s) Summary
Generic alert payload and request headers
src/push/apns.rs
Builds generic alert payloads from configuration, omits empty alert fields, and conditionally sets the apns-collapse-id header across payload modes with updated request tests.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding a configurable generic_alert APNs payload mode.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@stage-review

stage-review Bot commented Jul 12, 2026

Copy link
Copy Markdown

Ready to review this PR? Stage has broken it down into 4 individual chapters for you:

Title
1 Define configuration for generic alert mode
2 Implement APNs payload and header generation
3 Update test boilerplate for config changes
4 Document and expose new configuration
Open in Stage

Chapters generated by Stage for commit c96d4a8 on Jul 12, 2026 8:23am UTC.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/push/apns.rs (1)

914-944: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider a symmetric test for the title-empty / body-only case.

test_generic_alert_mode_omits_empty_body_from_alert covers title-set/body-empty. The doc comment on ApnsGenericAlert::title (Line 145-147) explicitly calls out the mirror case ("body-only alert" when title is empty), but there's no test asserting that shape. As per coding guidelines, **/*.rs changes should "Include tests for new functionality."

✅ Suggested additional test
#[test]
fn test_generic_alert_mode_omits_empty_title_from_alert() {
    let mut config = test_config();
    config.payload_mode = ApnsPayloadMode::GenericAlert;
    config.alert_title = String::new();
    config.alert_body = "You have a new notification".to_string();
    let client = ApnsClient::mock(config, false);
    let request_parts = ApnsRequestParts::for_config(&client.config);

    let request = client
        .build_request(
            "https://api.push.apple.com/3/device/aabbccdd11223344",
            "test-token",
            &request_parts,
        )
        .build()
        .unwrap();

    assert_eq!(
        request_json(&request),
        serde_json::json!({
            "aps": {
                "alert": {
                    "body": "You have a new notification"
                },
                "sound": "default"
            }
        })
    );
}
🤖 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/push/apns.rs` around lines 914 - 944, Add a symmetric test alongside
test_generic_alert_mode_omits_empty_body_from_alert for GenericAlert with an
empty alert_title and non-empty alert_body. Build the request through
ApnsClient::mock and ApnsRequestParts::for_config, then assert the JSON alert
contains only the body plus the existing sound field.

Source: Coding guidelines

🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/config.rs`:
- Around line 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.

In `@src/push/apns.rs`:
- Around line 162-180: Validate non-empty config.collapse_id during
configuration loading and reject values that cannot be represented as an HTTP
header, including control characters such as newlines and tabs. Reuse the
existing HeaderValue validation approach, then keep ApnsRequestParts::for_config
limited to cloning already-validated values before request.header is called.

---

Nitpick comments:
In `@src/push/apns.rs`:
- Around line 914-944: Add a symmetric test alongside
test_generic_alert_mode_omits_empty_body_from_alert for GenericAlert with an
empty alert_title and non-empty alert_body. Build the request through
ApnsClient::mock and ApnsRequestParts::for_config, then assert the JSON alert
contains only the body plus the existing sound field.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 084b0124-0431-48c5-8c20-aec7dcf68d3d

📥 Commits

Reviewing files that changed from the base of the PR and between aecb635 and c96d4a8.

📒 Files selected for processing (9)
  • CHANGELOG.md
  • README.md
  • config/default.toml
  • config/production.toml.example
  • src/config.rs
  • src/nostr/events/processor.rs
  • src/push/apns.rs
  • src/push/dispatcher/mod.rs
  • src/server/health.rs

Comment thread src/config.rs
Comment on lines +1300 to +1307
// 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()
)));
}

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.

Comment thread src/push/apns.rs
Comment on lines 162 to 180
struct ApnsRequestParts {
push_type: &'static str,
priority: &'static str,
/// `apns-collapse-id` header value; `None` leaves the header off.
collapse_id: Option<String>,
payload: ApnsPayload,
}

impl ApnsRequestParts {
fn for_mode(mode: ApnsPayloadMode) -> Self {
fn for_config(config: &ApnsConfig) -> Self {
let mode = config.payload_mode;
Self {
push_type: mode.push_type(),
priority: mode.priority(),
payload: ApnsPayload::from(mode),
collapse_id: (!config.collapse_id.is_empty()).then(|| config.collapse_id.clone()),
payload: ApnsPayload::for_config(config),
}
}
}

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 reqwest RequestBuilder::header return an Err instead of panicking when given an invalid header value string, and does that error propagate through send()?

💡 Result:

The reqwest::RequestBuilder::header method does not panic when provided with an invalid header value; instead, it stores the error internally, which then manifests when the request is sent [1][2][3]. The header method is generic and utilizes TryFrom to convert the provided key and value into HeaderName and HeaderValue types [2][3]. If the conversion fails (e.g., due to an invalid header value string), the resulting error is captured within the RequestBuilder [1][3]. When you subsequently call send() (or other methods that finalize the request), that stored error is returned, causing the operation to return an Err rather than panicking [1]. This design allows you to chain header additions and handle potential errors at the point where the request is actually executed [1][4]. If you need to validate header values beforehand, you can manually convert them using HeaderValue::from_str or HeaderValue::try_from, which return a Result<HeaderValue, InvalidHeaderValue> [5][6][7].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant files and inspect the specific regions mentioned in the review.
git ls-files src/push/apns.rs src/config.rs
echo '--- apns.rs outline ---'
ast-grep outline src/push/apns.rs --view expanded || true
echo '--- config.rs outline ---'
ast-grep outline src/config.rs --view expanded || true
echo '--- apns.rs lines around collapse_id usage ---'
sed -n '150,190p' src/push/apns.rs
echo '--- apns.rs build_request area ---'
sed -n '440,480p' src/push/apns.rs
echo '--- config.rs collapse_id validation/search ---'
rg -n "collapse_id|HeaderValue|length|64|apns" src/config.rs
sed -n '1,260p' src/config.rs

Repository: marmot-protocol/transponder

Length of output: 41720


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Probe the local code semantics around collapse_id handling and APNs request building.
python3 - <<'PY'
from pathlib import Path
import re

apns = Path('src/push/apns.rs').read_text()
config = Path('src/config.rs').read_text()

# Extract the relevant snippets for quick inspection.
for label, text, pattern in [
    ('collapse_id field', apns, r'collapse_id: Option<String>,.*?for_config\(config: &ApnsConfig\) -> Self \{.*?\n    \}',),
]:
    pass

# Simple checks for validation presence.
print('config contains HeaderValue::from_str:', 'HeaderValue::from_str' in config)
print('config contains collapse_id non-empty check:', '!config.collapse_id.is_empty()' in apns)
print('apns build_request uses .header("apns-collapse-id", collapse_id):', '.header("apns-collapse-id", collapse_id)' in apns)

# Search for tests around collapse_id validation.
for path in ['src/config.rs', 'tests', 'src']:
    pass
PY

Repository: marmot-protocol/transponder

Length of output: 331


Validate apns.collapse_id as a header value src/config.rs:1300-1305; src/push/apns.rs:176,461

collapse_id is capped at 64 bytes, but it still accepts control characters and is later passed straight into request.header("apns-collapse-id", ...). Reject non-header-safe values at config load time, for example with HeaderValue::from_str, so a pasted newline or tab doesn't make every APNs send for that config fail at runtime.

🤖 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/push/apns.rs` around lines 162 - 180, Validate non-empty
config.collapse_id during configuration loading and reject values that cannot be
represented as an HTTP header, including control characters such as newlines and
tabs. Reuse the existing HeaderValue validation approach, then keep
ApnsRequestParts::for_config limited to cloning already-validated values before
request.header is called.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant