fix(push-gateway): request default notification sound (#8431)

Push banners arrive silently because the gateway's fixed APNs body never
requests a sound, even though the iOS app requests sound permission. Add
`aps.sound: "default"` to that constant so ordinary alerts request the
system sound even if the notification extension is not invoked. Preserve
the existing alert text, mutable-content flag, and class-blind,
content-free delivery contract; synchronize its normative specification
and executable models.

Companion client workaround:
[#8429](https://github.com/block/buzz/pull/8429). This gateway change
also covers the original-payload fallback and requires no mobile source
change. Sound remains subject to iOS settings and authorization. It does
not bypass Focus or Silent Mode, select critical alerts, or guarantee
vibration. Every public-profile wake requests the same sound, including
generic/restricted fallback banners.

### Validation

- Extended the real HTTP transport test: reproduced missing sound
(`null` versus `default`) before the fix, then passed with the fix. All
79 runnable gateway library tests pass; 17 infrastructure/manual tests
remain ignored.
- Added a guard against drift between the emitted bytes, NIP-PL, and
both formal models. Fixed-payload and stateful-delivery models and
mutation checks pass.
- Inspected the 0.24.0 (7) iOS source: sound permission is requested;
ordinary enrichment and restricted fallback copy incoming content. A
macOS UserNotifications probe confirms copy retention, but does not test
iOS Intents or audible device delivery.
- Focused existing native resolver/handoff tests pass (35 tests on
macOS). These cover resolution and fallback behavior, not iOS sound
playback.
- Deployed exact head
[b8a0eb6](b8a0eb6b78)
to the personal dev push gateway (Railway deployment
`b3e52fc8-d1d3-48d7-ae21-1a83c5db2e99`). Verified the dev relay already
routes to that gateway. On an iPhone 12 mini running the existing Buzz
Dev 0.22 (10140), without the client workaround, automation captured the
test banner and Tom confirmed audible sound on October 18.
- Broad local validation reached an unrelated ACP startup-deadline
failure in
`signals_cancel_startup_turn_and_pending_stdin_without_hanging`. No ACP
source or dependency changed, and ACP does not depend on the gateway.
The full local gate is not reported as passing.
- Local desktop pre-push tests passed 3,420 cases but failed two
unchanged agent-discovery checks (a 300 ms subprocess deadline and a
login-shell PATH comparison). Desktop does not depend on the gateway.
Retried publication with only the Rust-suite and desktop-Tauri lanes
excluded under the standing unrelated-failure exception; other hooks
remain enabled.


### Before merge

- [x] Verify a real push banner and audible sound through the dev relay
and candidate dev gateway on an iOS device without the client
workaround.

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-18 15:59:19 -07:00
committed by GitHub
parent bbb5183f33
commit 305fb7b5cd
5 changed files with 21 additions and 4 deletions

View File

@@ -239,6 +239,18 @@ mod tests {
StatusCode::OK
}
#[test]
fn apns_payload_matches_normative_contract_and_models() {
let body = std::str::from_utf8(APNS_RECONNECT_PAYLOAD).unwrap();
for contract in [
include_str!("../../../docs/nips/NIP-PL.md"),
include_str!("../../../docs/formal/nip-pl/delivery.py"),
include_str!("../../../docs/formal/nip-pl/fixed_payload.py"),
] {
assert!(contract.contains(body), "APNs fixed-body contract drift");
}
}
#[tokio::test]
async fn certificate_transport_sends_no_bearer_and_exact_body_for_every_attempt() {
let requests = Arc::new(Mutex::new(Vec::new()));
@@ -274,6 +286,11 @@ mod tests {
}
let captured = requests.lock().unwrap();
assert_eq!(captured.len(), 2);
for request in captured.iter() {
let payload: serde_json::Value = serde_json::from_slice(&request.body).unwrap();
assert_eq!(payload["aps"]["sound"], "default");
assert_eq!(payload["aps"]["mutable-content"], 1);
}
assert!(captured
.iter()
.all(|request| request.body.as_slice() == APNS_RECONNECT_PAYLOAD));

View File

@@ -13,7 +13,7 @@ pub const MAX_ENROLL_REQUEST_BYTES: usize =
pub const MAX_GRANT_BYTES: usize = 4096;
pub const MAX_ENDPOINT_HEX_BYTES: usize = 512;
pub const APNS_RECONNECT_PAYLOAD: &[u8] =
br#"{"aps":{"alert":{"body":"Reconnect to your relay now"},"mutable-content":1}}"#;
br#"{"aps":{"alert":{"body":"Reconnect to your relay now"},"sound":"default","mutable-content":1}}"#;
pub const WIRE_VERSION: u8 = 1;
/// Relay request. It deliberately has no application-payload field:

View File

@@ -7,7 +7,7 @@ transient release, revocation ordering, custody, and the constant APNs body.
"""
from itertools import permutations, product
FIXED_BODY = b'{"aps":{"alert":{"body":"Reconnect to your relay now"},"mutable-content":1}}'
FIXED_BODY = b'{"aps":{"alert":{"body":"Reconnect to your relay now"},"sound":"default","mutable-content":1}}'
class Gateway:
def __init__(self):

View File

@@ -5,7 +5,7 @@ caller-controlled or capability-derived category; none is an argument to body().
"""
from itertools import product
C = b'{"aps":{"alert":{"body":"Reconnect to your relay now"},"mutable-content":1}}'
C = b'{"aps":{"alert":{"body":"Reconnect to your relay now"},"sound":"default","mutable-content":1}}'
DOMAINS = [
(b"request-a", b"request-b"), # exact signed body
(b"auth-a", b"auth-b"), # NIP-98 event/header

View File

@@ -224,7 +224,7 @@ Common invariant, all transports: the application payload is a transport-owned r
### APNs
The APNs application body is the exact UTF-8 byte constant `{"aps":{"alert":{"body":"Reconnect to your relay now"},"mutable-content":1}}`. It has no custom member, event identifier, unread count, or relay-supplied byte. The constant mutable-content flag lets the Buzz Notification Service Extension compute a local badge and, when separately authorized data is available, replace the generic text; the gateway does not carry that data. The gateway MUST send that exact body for every accepted APNs attempt; it MUST NOT serialize any relay request, endpoint grant, provider response, or generic JSON value into the body. `apns-topic`, environment, credentials, push type `alert`, and priority `10` come only from gateway configuration. `apns-id` is a canonical UUID and `apns-expiration` is bounded by the endpoint capability and a gateway-local ceiling.
The APNs application body is the exact UTF-8 byte constant `{"aps":{"alert":{"body":"Reconnect to your relay now"},"sound":"default","mutable-content":1}}`. It has no custom member, event identifier, unread count, or relay-supplied byte. The constant sound requests the system default alert sound, subject to notification authorization and user sound, Focus, and haptics settings. It does not select critical alerts or guarantee vibration. Sound is present even when the Notification Service Extension does not run. The constant mutable-content flag lets the Buzz Notification Service Extension compute a local badge and, when separately authorized data is available, replace the generic text; the gateway does not carry that data. The gateway MUST send that exact body for every accepted APNs attempt; it MUST NOT serialize any relay request, endpoint grant, provider response, or generic JSON value into the body. `apns-topic`, environment, credentials, push type `alert`, and priority `10` come only from gateway configuration. `apns-id` is a canonical UUID and `apns-expiration` is bounded by the endpoint capability and a gateway-local ceiling.
### FCM