mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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>
105 lines
4.0 KiB
Python
105 lines
4.0 KiB
Python
"""Bounded model of the shipped stateful public-gateway authority plane.
|
|
|
|
The model deliberately excludes the relay matcher (not shipped here) and checks the
|
|
linearization rules that the gateway does ship: current epoch/generation authority,
|
|
relay confinement, expiry, replay admission, quota charging, terminal burn versus
|
|
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"},"sound":"default","mutable-content":1}}'
|
|
|
|
class Gateway:
|
|
def __init__(self):
|
|
self.relay = "relay-a"
|
|
self.epoch = 1
|
|
self.generation = 1
|
|
self.revoked = False
|
|
self.installation_expires = 100
|
|
self.grant_expires = 80
|
|
self.auth_replays = set()
|
|
self.request_replays = set()
|
|
self.quota = 0
|
|
self.sends = []
|
|
|
|
def admit(self, relay="relay-a", epoch=1, generation=1, now=10,
|
|
request_expires=50, auth_id="auth-1", request_id="request-1",
|
|
custody_ok=True):
|
|
if (self.revoked or relay != self.relay or epoch != self.epoch or
|
|
generation != self.generation or now > self.installation_expires or
|
|
now > self.grant_expires or now > request_expires or
|
|
request_expires > self.grant_expires or auth_id in self.auth_replays or
|
|
request_id in self.request_replays):
|
|
return False
|
|
# One durable admission commit: both replay fences and non-refundable quota.
|
|
self.auth_replays.add(auth_id)
|
|
self.request_replays.add(request_id)
|
|
self.quota += 1
|
|
if not custody_ok:
|
|
self.finish(request_id, "transient")
|
|
return False
|
|
self.sends.append((request_id, FIXED_BODY))
|
|
return True
|
|
|
|
def finish(self, request_id, outcome):
|
|
if outcome == "transient":
|
|
self.request_replays.discard(request_id)
|
|
elif outcome != "terminal":
|
|
raise ValueError(outcome)
|
|
|
|
def rotate(self):
|
|
self.epoch += 1
|
|
|
|
def revoke(self):
|
|
self.revoked = True
|
|
|
|
|
|
def explore():
|
|
checked = 0
|
|
for relay_ok, epoch_ok, gen_ok, grant_live, request_live, custody_ok in product(
|
|
[False, True], repeat=6
|
|
):
|
|
checked += 1
|
|
g = Gateway()
|
|
admitted = g.admit(
|
|
relay="relay-a" if relay_ok else "relay-b",
|
|
epoch=1 if epoch_ok else 0,
|
|
generation=1 if gen_ok else 0,
|
|
now=10,
|
|
request_expires=50 if request_live else 9,
|
|
custody_ok=custody_ok,
|
|
) if grant_live else g.admit(now=81)
|
|
expected = all((relay_ok, epoch_ok, gen_ok, grant_live, request_live, custody_ok))
|
|
assert admitted == expected
|
|
assert all(body == FIXED_BODY for _, body in g.sends) # fixed-body noninterference
|
|
|
|
# Whichever authority mutation commits first determines admission.
|
|
for actions in permutations(("admit", "revoke")):
|
|
checked += 1
|
|
g = Gateway(); result = None
|
|
for action in actions:
|
|
result = g.admit() if action == "admit" else (g.revoke() or result)
|
|
assert result == (actions[0] == "admit")
|
|
|
|
# Terminal outcomes burn the request; transient outcomes release only request-id,
|
|
# while every auth event remains burned and every admitted attempt charges quota.
|
|
for outcome in ("terminal", "transient"):
|
|
checked += 1
|
|
g = Gateway(); assert g.admit()
|
|
g.finish("request-1", outcome)
|
|
assert not g.admit(auth_id="auth-1", request_id="request-2")
|
|
retry = g.admit(auth_id="auth-2", request_id="request-1")
|
|
assert retry == (outcome == "transient")
|
|
assert g.quota == (2 if retry else 1)
|
|
|
|
# Rotation invalidates old grants; a current grant remains relay-confined.
|
|
g = Gateway(); g.rotate(); checked += 1
|
|
assert not g.admit(epoch=1)
|
|
assert g.admit(epoch=2)
|
|
return checked
|
|
|
|
if __name__ == "__main__":
|
|
n = explore()
|
|
print(f"stateful delivery combinations/interleavings checked: {n}")
|
|
print("stateful gateway invariants: HOLD")
|