Commit Graph

10 Commits

Author SHA1 Message Date
Tom Brow
041956ea44 fix(ios): saturate the push dedupe cutoff so extreme timestamps can't crash (#9010)
## Context

Follow-up to [#8973](https://github.com/block/buzz/pull/8973), from
Codex's
[review](https://github.com/block/buzz/pull/8973#discussion_r4458876285)
of its final head. The push dedupe cutoff subtracts the 15 minute
reorder window from the newest shown time. A signed event can carry any
`created_at`. One within 900 seconds of `Int.min` made that subtraction
overflow and trap, crashing the Notification Service Extension. The
claim was never saved, so every later push crashed the same way while
the relay kept returning that event. The relay's own drift check
normally rejects such timestamps, but the extension shouldn't depend on
that.

## Change

The cutoff now uses saturating subtraction
(`BuzzNotificationCache.presentationCutoff(after:)`), in both the claim
check and retention pruning.

## Validation

- New test `testExtremeTimestampsDoNotCrashTheClaim` claims messages
dated `Int.min`, `Int.min + 1`, now, and `Int.max`. It checks that each
behaves like any other message and nothing crashes. With the plain
subtraction restored, it crashes with signal 5 (overflow trap).
- Full BuzzPushKit suite: 108 XCTest and 66 Swift Testing tests passed.
- Automated app test: not run. This is a two-line arithmetic change
inside the extension's cache. The live APNs run on
[#8973](https://github.com/block/buzz/pull/8973) covers the flow.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:49:54 -08:00
Tom Brow
bf13f7c83b fix(ios): mint NIP-FI assertions in the notification extension (#8982)
Organization relays that enforce NIP-FI (federated identity) require a
`Nostr-Federated-Identity` assertion on `/query`. The iOS notification
service extension signed its `/query` with NIP-98 only, so the relay
answered `401` and every push from an enforcing organization relay
showed the generic "You have a new message." body.

The app already mints these assertions in Dart from its organization
account session, but that session lives in the app's own keychain, and
assertions last minutes, so a cached one is useless by the time a push
arrives. This change lets the extension mint its own:

- **App:** whenever the organization `BuilderlabAccountClient` adopts or
clears its session, the app writes or deletes a device-only keychain
item in the group it already shares with the extension. The item holds
the organization origin, the session credential and the organization's
exact relay hosts (from `BUZZ_ORGANIZATION_RELAYS`). Builds without an
organization never write it.
- **Extension:** for a community whose relay host is one of those exact
hosts, `BuzzRelayAssertionIssuer` requests an assertion from the
organization issuer (`/api/goose/v1/buzz/identity/assertions`, session
credential plus NIP-98 with the community's account key, the same
request the app makes), checks it was issued for that key, and caches it
in-process until 30 seconds before expiry. Both `/query` paths (message
lookup and sender/channel enrichment) send it. Other relays get no
header and never see the session.
- Signing out awaits removal of the shared copy and fails if removal
fails; each restore that finds no session retries the removal.
- If issuance fails or the user is signed out, the extension sends the
query without the header and the relay refuses it, which keeps today's
generic fallback.

Not covered here: relay-hosted avatars on enforcing relays still load
without the assertion (the notification text and title are unaffected),
hosted Builderlab communities, and Android.

### Validation

Added tests:
- `testOrganizationRelayQueriesCarryMintedAssertion`: against a stub
relay that answers `401` without the assertion, two organization pushes
resolve with message body and sender name from a single issuance, a
non-organization relay gets neither the header nor the session, and
after sign-out the notification falls back. Removing the header from the
query path makes this test fail.
- `testOrganizationAssertionIssuanceFailuresSendNoHeader`: refused,
wrong-key, expired and header-injection responses produce no header.
Subdomains and other ports of the organization relay host are never sent
the session.
- `builderlab_account_test.dart`: the shared copy follows exchange,
restore and clear; a failed removal fails sign-out, and the next restore
retries it.

Focused existing validation: the full BuzzPushKit suite, the hosted Dart
tests, `flutter analyze`, and an iOS device build of the app and
extension.

Not yet validated: a live push from the Block relay on a device.

## Before merge

Live device push test: waived by Tom (2026-11-03).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 19:01:42 +00:00
Tom Brow
7d40a66126 fix(ios): stop repeating an old message in push notifications (#8973)
## Context

Pushes from the gateway carry no event reference
(`crates/buzz-push-gateway/src/model.rs`), so the iOS Notification
Service Extension resolves each push by querying the user's NIP-PL lease
filters and showing the newest matching message. Any push whose trigger
wasn't the newest message (a reaction, an edit or deletion, a community
query that failed, or several pushes arriving for one message) showed
that same newest message again. Tom saw one message notify four times
over three hours.

## Behavior

- The extension now remembers which messages it has shown, per community
and account, in its existing notification cache
(`BuzzNotificationCache`).
- Each push shows the newest message not yet shown, across all
communities. If every candidate was already shown, it shows the generic
"You have a new message." text.
- A message more than 15 minutes older than the newest one already
shown, in any configured community (including one whose query failed),
is treated as already covered. So a stale backlog item can't surface
hours later, even from a community where nothing has been shown yet. 15
minutes matches the relay's `created_at` drift bound
(`MAX_TIMESTAMP_DRIFT_SECS`), so a sender with a slow clock still gets
through. A future-dated `created_at` is capped at the time it was shown,
so it can't hold back later messages.
- Selection and claim happen in one locked cache call. It reads each
community's store once and writes only when it records a claim, so
concurrent pushes can't claim against a stale view, and a rejected
candidate never rewrites the store.
- Retention drops only entries the age check would reject anyway. The
rest are capped at the union of the newest 256 by capped time and the
newest 256 by `created_at`. So neither a burst of messages nor
future-dated entries can evict a message the relay still returns. The
field is optional, so existing caches still decode with no version bump.
- Only the newest 10 matches per community are fetched, as before. If
more than 10 messages arrive before their pushes resolve, the extra
pushes show the generic text.
- The claim happens before presentation. If presentation then fails,
that push shows the generic text and the message isn't offered again,
which is better than repeating.
- If the cache can't be read, dedupe is skipped (fail open), so new
messages are never hidden. If a claim can't be written, the new message
is still shown. An already-shown message is still rejected, since
rejecting needs no write. A revoked scope is skipped.
- Dedupe state lives in the extension's `Caches` directory, which iOS
can purge. After a purge, the next push can show the newest message
again, once.

This is the NSE-only fix. It doesn't change the gateway payload or
NIP-PL.

## Validation

- New test `testEachPushShowsANewMessageAndNeverRepeatsOne` uses a fresh
cache instance per push, to model separate extension processes. It
covers: a repeat push giving the generic text; out-of-order resolution
(late, then early, then nothing); a stale message outside the window;
and the future-date clamp. I checked by mutation that removing any one
of the three guards (id check, window, clamp) fails it.
- New test
`testShownMessagesStayShownAcrossCommunitiesFailuresAndBursts` covers
the review findings. It checks that:
- an old message isn't shown from a community with nothing shown yet,
including when the other community's query fails;
- a burst of 100 newer messages doesn't evict a shown mention the relay
still returns;
- a read-only store neither repeats a shown message nor hides a new one;
  - a future-dated message isn't shown again after later messages.
- New test `testFutureDatedEntriesDoNotEvictAClaimedMessage`: 257
future-dated claims don't evict a just-claimed message.
- Mutation checks all fail these tests: a candidate-only watermark,
retention by `created_at` only, a 64-entry cap, and pruning by capped
time.
- Full BuzzPushKit suite on `15913d0`: 106 XCTest and 65 Swift Testing
tests passed.
- Live relay run (on `baac384`): a scratch test drove the real resolver
and cache against a local relay. One key posted messages and another
resolved five pushes (not committed):

  | Push | `main` | This branch |
  | --- | --- | --- |
  | 1 (after "Hello 1") | Hello 1 | Hello 1 |
  | 2 (no new message) | Hello 1 | You have a new message. |
  | 3 (after "Hello 2", "Hello 3") | Hello 3 | Hello 3 |
  | 4 (no new message) | Hello 3 | Hello 2 |
  | 5 (no new message) | Hello 3 | You have a new message. |

- Automated app check: built and launched the branch on an iOS 27
simulator. The app started and showed the signed-in home screen. Only
the extension uses the changed types.
- An independent fresh-context review found no blockers. Its comment
suggestions were applied.

### Limitations

I couldn't run a real APNs push end to end. Dev-signed builds get no
gateway pushes, and `simctl push` on iOS 27 doesn't invoke the
Notification Service Extension. The live relay run covers the resolver
and cache logic the extension runs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:44:07 -07:00
Tom Brow
ff6ab96e42 fix(mobile): drop the # from Solo chat names in push notifications (#8955)
## Summary

Push notifications for Solo chats were titled like channels (`#Trip
planning`). The iOS notification extension added `#` to every stream and
forum conversation name, but the app presents Solo chats as personal
chats, not channels.

- `BuzzPushCachedChannel` gains optional `isSolo` and `creatorPubkey`,
derived from relay kind 39000 metadata. `isSolo` uses the same rule as
the app's `Channel.isSolo`: a private stream whose NIP-CL labels (every
`t` after the leading type) include `buzz.solo`. `creatorPubkey` is the
relay-authored `P` tag.
- The resolver uses the bare channel name only for the viewer's own Solo
chats (matching `Channel.isPersonalChat`, which requires `createdBy ==
viewer`). Someone else's Solo chat, and every other channel, keeps
`#name`.
- `isSolo` is optional, so existing App Group caches still decode.
Entries cached before this change have no flag and keep the `#` until
their metadata is refreshed.

## Validation

- `swift test --package-path mobile/ios/BuzzPushKit`: 104 XCTest and 66
Swift Testing tests pass, including two new ones:
- `testOnlyOwnSoloChatConversationNameDropsChannelHash`: a fresh Solo
metadata fetch yields `Trip planning` when the viewer created the chat
and `#Trip planning` when someone else did. Removing the creator check
makes it fail.
- `soloClassification`: only private `stream` channels labelled
`buzz.solo` are marked Solo (open, forum, label-only and unlabelled
private streams are not), and the `P` creator is cached lowercased.
- Live relay check: a local relay with synthetic identities, a Solo chat
("Weekend plans") and a private channel ("design-review"), each with a
second member who posts. The production `BuzzPushNotificationResolver`
(the code the notification extension calls), run against relay-built
kind 39000 metadata, gives:

  | | Solo chat | Channel |
  |---|---|---|
  | main | `#Weekend plans` | `#design-review` |
  | this PR | `Weekend plans` | `#design-review` |

- Not covered: a banner rendered by the notification extension. `simctl
push` delivers to the simulator as a local notification and never
launches the extension, and a real APNs push needs credentials this
machine doesn't have.

## Follow-up after merge

Merging on the live relay check above. Still to do once a dogfood build
includes this change:

- [ ] On-device push check on a BLIMP: a Solo chat push shows the bare
name and a channel push keeps `#name`.
- [ ] Before/after notification screenshots.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 18:50:08 -08:00
Tom Brow
d263e2c7f7 fix(mobile): resolve iOS pushes on a fresh connection and retry a stale one (#8722)
## Summary

iOS users were seeing a run of push notifications that just said
"Reconnect to your relay now". That is the push gateway's fixed APNs
body. It shows when the Notification Service Extension (NSE) can't fetch
the message from the relay.

On-device logs from one affected iPhone over a full day showed 12 of 385
NSE runs fail to resolve. Each failed relay `/query`: 10 hit the 8
second timeout and 2 got `NSURLErrorNetworkConnectionLost` (-1005). All
12 ran on a reused pooled connection from `URLSession.shared` in a
long-lived NSE process. That process lived for hours while the network
path changed between VPN and cellular. Of the fresh connections, 0 of
473 failed.

This change:

- Gives each notification its own ephemeral session, so it never
inherits a pooled connection. The session is cancelled when the
extension finishes or expires.
- Retries `/query` once if it times out or loses its connection
mid-request. The retry uses a new session and a freshly signed NIP-98
proof, because the relay rejects a replayed proof. Other failures (HTTP
errors, offline, cancellation) are not retried. The retry is bounded to
one attempt, so the worst case is about 16 seconds, inside the NSE's
roughly 30 second budget.
- Replaces the gateway body with "You have a new message." when the NSE
can't resolve the push. The restricted fallback ("Open Buzz to view this
message.") is unchanged, and so is the gateway payload.

### Related issue

N/A. Reported in Buzz #buzz-mobile.

### Validation

- Added tests in `BuzzPushNotificationResolverTests`:
- A timeout or -1005 is retried once, on a new session and with a
different `Authorization` proof, and resolves.
  - Repeated timeouts stop after 2 attempts.
  - `notConnectedToInternet` is not retried.
- The retry is skipped if the account key changed during the first
attempt.
- `cancel()` (called when the NSE finishes or expires) stops a pending
retry.
- Each new test fails when the behavior it covers is removed: the retry,
the new session, the account recheck, or the cancel guard.
- The full BuzzPushKit `swift test` suite passes.
- A debug simulator build of Runner and the NotificationService
extension succeeded.
- An agent review of the diff found no blockers. Its suggestions to
create the session per `didReceive` and to cancel it on expiry are
included.
- Limitations:
- The NSE target has no unit tests, so the fallback text and the session
lifecycle are covered only by build and review.
- I couldn't send a real push through the NSE. Test phones run a
personal-team bundle that isn't the gateway's APNs topic. In the
simulator, `simctl push` needs notification permission, which the app
only requests after joining a community.
- Under the mobile testing exception, the human-test step does not
apply.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 00:34:12 +00:00
Tom Brow
73962435b6 Use upstream C secp256k1 for native Nostr signatures (#8398)
Replace the Swift secp256k1 wrapper with a small native adapter over
Bitcoin Core libsecp256k1. This is an alternative to the wrapper upgrade
in [#8381](https://github.com/block/buzz/pull/8381), not an additional
change to merge alongside it.

The adapter covers the three Nostr operations Buzz needs: x-only
public-key derivation, BIP-340 signing, and signature verification. It
compiles pinned, unmodified upstream C source through SwiftPM. There is
no build plugin, plugin approval script, or underscored Swift import.
The app and notification extension use an explicit C module map and
header search path.

This trades the third-party Swift wrapper for a Buzz-owned adapter and
responsibility for updating the vendored C source. Upstream v0.8.0
source, its license, and update provenance are included. The release
Xcode pin remains separate in
[buzz-releases#110](https://github.com/squareup/buzz-releases/pull/110).

### Validation

- Native package tests passed on Xcode 27.0 and 26.5: 96 XCTest tests
and 65 Swift Testing tests per toolchain.
- Added tests cover the official BIP-340 vectors for 32-byte Nostr
messages, invalid secret keys, and malformed input lengths.
- Full unsigned Release app and notification extension build passed on
exact Xcode 27.0.
- Vendored files were compared byte-for-byte with upstream v0.8.0.
- Full local `just ci` passed.
- [Hosted full unsigned iOS Release
build](https://github.com/block/buzz/actions/runs/38924020583/job/117051237094)
passed. [Hosted Native Swift
validation](https://github.com/block/buzz/actions/runs/38924020583/job/117051237153)
passed, including the complete simulator build and native controls/push
navigation tests.
- Codex code review and security review completed with no findings.

### Before merge

- [x] Tom selected the direct-C approach with vendored upstream source;
[the wrapper alternative](https://github.com/block/buzz/pull/8381) is
closed.

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-16 09:26:55 -07:00
Tom Brow
fea02d174d fix(ios): fetch profiles and avatars in notification extension (#8369)
A notification can arrive from someone whose profile the main app has
never loaded. The iOS notification extension now fetches and caches
sender profiles, channel details, and avatars itself. The main app
supplies account configuration and Keychain credentials only.

After resolving the message, the extension allows 1.5 seconds for sender
and channel details. It uses available stale data when a refresh is slow
or fails, delivers once, and lets late results update its private cache.
Profiles, channel details, and avatar refresh checks share a one-hour
freshness threshold. A verified avatar removal clears the previous
image. Account removal prevents further use of that sign-in's cached
data.

Profile, channel metadata, and membership use separate bounded
downloads, so an oversized roster cannot discard the smaller records.
Sender enrichment can finish while channel requests are pending. A
successful partial channel refresh atomically removes its old
counterpart, preventing a mixed group identity or recipient count.
Group-specific notification presentation still requires a verified
recipient count.

The
[specification](https://github.com/block/buzz/blob/native-notification-enrichment/docs/native-notification-enrichment.md)
describes the display, caching, and account-removal behavior. General
persistent image caching in Flutter remains separate.

### Validation

[Hosted CI](https://github.com/block/buzz/actions/runs/38780108487)
passed on the final commit, including all Flutter shards, Android, the
full iOS release and simulator builds, and native
controls/push-navigation tests. The [current-commit security
review](https://github.com/block/buzz/pull/8369#issuecomment-6197508385)
found no issues.

The native package passed 159 tests, including new coverage for cache
freshness, avatar replacement and removal, sign-in isolation, concurrent
storage, late results, single delivery, bounded downloads, and SVG
validation. A regression test exposed identical HTTP authentication
event IDs for requests in the same second; each request now includes a
unique nonce, matching the Dart and CLI clients.

Full local CI passed before that small authentication fix. The complete
native suite and signed device build passed again on the latest commit,
including response-size limits, overlapping avatar downloads, canonical
relay-origin matching, independent profile/channel refreshes,
account-key changes before and during requests, and a single initial
snapshot read across 64 accounts, signed IPv6 avatar authorities, and
partial channel refreshes. Focused Flutter regression tests passed 198
tests with three configuration-specific skips; seven avatar widget tests
also passed. Flutter analysis is clean, and the notification extension
builds for the simulator.

On an iPhone SE, the notification displayed the sender name, avatar,
channel, and message before the updated main app was opened. Repeat
notifications used a byte-identical private cache. Three notifications
delivered together displayed the avatar after the authentication fix.
The next notification also displayed correctly after terminating the
extension process. The existing generic-push design can select the same
newest unread message for several pushes; this change does not alter
message selection.

![Notification before first app launch after
upgrade](https://raw.githubusercontent.com/block/buzz/37d450855e026178836e2a9e05b0081ebbc3b479/pr-8369--before-first-app-launch.png)

Authenticated HTTP avatar fetching and persistent HTTP cache reuse also
passed on the SE. The first notification downloaded a relay thumbnail.
After terminating the extension and expiring its separate avatar entry,
the next notification displayed the avatar and refreshed that entry
without another media request in the relay logs. The main app remained
unopened throughout. The original app build and test profile were
restored afterward.

Automated tests cover slow and failed requests, stale images, removal,
and account revocation; those cases have not all been exercised on the
phone.

### Before merge

- [x] Verify authenticated HTTP avatar fetching and cache reuse on iOS
against a working relay media service.

<!-- buzz-review-completed -->

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-15 11:16:04 -07:00
Tom Brow
7b9551aba8 fix(ios): open push replies in their containing thread (#8294)
Tapping a push notification for a thread reply currently opens the
channel at its parent. Preserve the verified event's direct-parent
thread ID through the iOS notification payload and Flutter bridge so the
tap opens the containing thread and highlights that exact reply, even
when newer replies have arrived. This matches Inbox's destination
selection for nested replies; broadcast replies still open in the
channel.

Closest related work: https://github.com/block/buzz/pull/6159 (original
push-tap routing).

### Validation

- Follow-up
[5b70e1a](5b70e1a5cb)
selects the last valid 64-character ASCII-hex reply marker, matching
relay ingest. Regression tests failed before the fix; all 80 XCTest and
47 Swift Testing tests passed afterward with Xcode 26.5. [Current-head
CI](https://github.com/block/buzz/actions/runs/38607128406) passed,
including Flutter tests, Android build, native package tests, complete
iOS simulator and unsigned release builds, and native accessibility
tests. The [current-head security
review](https://github.com/block/buzz/pull/8294#issuecomment-6174205374)
reports no findings. Physical evidence below predates this
validation-only follow-up.

- [Baseline CI on
f465f91](https://github.com/block/buzz/actions/runs/38436936153) passed,
including Flutter and native package tests, Android build, complete iOS
simulator and unsigned release builds, and native accessibility tests.
- Full local validation passed on
[f465f91](f465f9196f),
including 3,215 Flutter tests (four skipped) and three unconfigured-push
tests. The 49 focused navigation tests also passed after the baseline
merge.
- Added coverage for cold/warm bridge delivery, exact reply highlighting
with newer arrivals, direct-parent and broadcast destinations, and
malformed optional thread IDs. The Codex finding for an empty reply
marker is fixed and resolved.
- Physical acceptance passed on the managed iPhone 12 mini using signed
Buzz Dev 10140, built from f465f91 with SDK 26.5. Real push taps and
Inbox opened the same containing thread and highlighted the exact reply.
An older alert retained its target after a newer reply arrived; a
retained alert also worked after terminating the app. Top-level push
navigation reached the channel.
- Installed in place with the existing identity, app data and pairing
retained. No key reset or rotation was performed. Notifications were
restored to off and device control released. Xcode 27 hits an unchanged
dependency compiler error; the matching Xcode 26.5 toolchain built and
signed this tested candidate successfully.

### Physical acceptance

| Warm push: older reply A | Inbox: reply B | Cold push: reply B |
| --- | --- | --- |
| ![Older reply A highlighted with newer B
visible](https://raw.githubusercontent.com/block/buzz/87eb00d209cf0b0dd3f4146e3219b15884133b9d/pr-8294--warm-older-reply.png)
| ![Inbox highlights reply
B](https://raw.githubusercontent.com/block/buzz/87eb00d209cf0b0dd3f4146e3219b15884133b9d/pr-8294--inbox-exact-reply.png)
| ![Cold push highlights reply
B](https://raw.githubusercontent.com/block/buzz/87eb00d209cf0b0dd3f4146e3219b15884133b9d/pr-8294--cold-push-exact-reply.png)
|

[Physical test
details](https://github.com/block/buzz/pull/8294#issuecomment-6151991840).

### Before and after

Synthetic Flutter widget captures with a newer reply already present.

| Before: channel at outer parent | After: exact reply in containing
thread |
| --- | --- |
|
![Before](https://raw.githubusercontent.com/block/buzz/d9a0ff6c7a5ea481b04cb9c61d301553b4c23c10/pr-8294--before.png)
|
![After](https://raw.githubusercontent.com/block/buzz/d9a0ff6c7a5ea481b04cb9c61d301553b4c23c10/pr-8294--after.png)
|

After merge, repeat warm/cold push versus Inbox in the integrated mobile
candidate, including older alerts, top-level and broadcast
notifications.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-14 12:35:58 -07:00
Logan Johnson
93761e411c fix(mobile): render push notification sender identity as npub (#7494)
🤖

## Summary

When an iOS push notification comes from someone the app has no cached
name for, the notification title showed the first characters of the
sender's raw public key — for example `aa4fc866…`. That fragment is
unreadable and doesn't match how the same person appears anywhere else
in Buzz. This PR changes that title to the compact form of the sender's
npub (npub is the human-readable encoding of a Nostr public key): first
8 and last 4 characters — for example `npub14f8…9nsy`, the same identity
shape used across the desktop and mobile apps.

- Unnamed senders: raw hex fragment → compact npub.
- Named senders: unchanged — a sender the app has a display name for
still titles the notification with that name.
- Unverifiable sender identities (malformed keys, or lookalike strings
that are not literal 64-hex-digit keys) now render a neutral "Someone"
instead of partial raw key material.
- Everything else about the notification is unchanged: body text,
subtitle, thread matching and grouping, deep-link navigation, thread
identifiers, and the internal hex public key the resolver matches on.

The native iOS notification-service package (`BuzzPushKit`) gains a
minimal in-house bech32 codec (bech32 is the checksummed string encoding
npubs use) — checksum-validated, 32-byte keys only, and no new external
dependency. The hex input branch accepts exactly a 64 ASCII hex digit
key before any parsing, so strings that merely parse like hex (for
example a run of `+a` pairs) cannot become a displayed identity; this is
input validation for presentation. Event signature verification is
untouched.

### Related issue

Fixes: N/A. Searched existing issues/PRs for push-notification npub
identity — closest related: none found.

### Testing

At head `3e3f2813b8864b76257ccb50dea3a4b31fa4de0d` (base
`44316ff72f5f7de014c66b01cbf534298a70c249`; 4 files, +321/−4):

- CI `Mobile Swift` lane, at this exact head — all passed: `swift test`
(73 tests, 0 failures), the SwiftPM debug and release builds of
`mobile/ios/BuzzPushKit`, and the unsigned iOS release build.
- Test coverage: npub encoding cross-checked against independent
nostr-rs/NIP-19 vectors; rejection of bad checksums, mixed case, wrong
lengths, invalid alphabet, padding, and non-32-byte payloads; resolver
boundary matrix — hex/npub/invalid sender keys render compact npub or
"Someone" while body, subtitle, sender key, and thread identifier pass
through; named senders keep cached display names.

### Task provenance

Buzz channel: `1f0e4a3d-7e01-4efe-bb16-843b357f85c9`

Task:
buzz://message?channel=1f0e4a3d-7e01-4efe-bb16-843b357f85c9&id=86b34eb4bd84a1472419e9af22636c011c0fe273e3c196f967d7a36996e149b6

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-09 10:31:42 -04:00
Tom Brow
c432a111ca feat(mobile): push notifications MVP (#6269)
This PR implements MVP, iOS-only,
[NIP-PL](8d2d0ff5ad/docs/nips/NIP-PL.md)-compliant
push notifications.

A relay with `BUZZ_PUSH_ENABLED` will send a push notification for any
message that appears in the in-app Notifications tab.

## Enrollment flow
The first time the client first connects to a relay with
`BUZZ_PUSH_ENABLED`:
```mermaid
sequenceDiagram
    autonumber
    participant App as Buzz iOS app
    participant iOS
    participant Relay as Buzz relay
    participant Attest as Apple App Attest
    participant Gateway as Push gateway

    App->>Relay: Fetch NIP-11 push capability
    Relay-->>App: Push profile, current relay public key, and limits

    par
        App->>iOS: Request notification permission
        iOS-->>App: Permission result
    and
        App->>iOS: Register for remote notifications
        iOS-->>App: Device token
    end

    App->>Gateway: Request installation challenge
    Gateway-->>App: Single-use challenge
    App->>Attest: Attest installation transcript
    Attest-->>App: Attestation proof
    App->>Gateway: Enroll device token and proof
    Gateway-->>App: Installation handle

    App->>Gateway: Request delegation challenge
    Gateway-->>App: Single-use challenge
    App->>Attest: Assert relay-key delegation
    Attest-->>App: Assertion
    App->>Gateway: Create delegation
    Gateway-->>App: Opaque endpoint grant

    App->>Relay: Publish encrypted push lease and filters
    Relay-->>App: Lease acknowledged
```

## Push-time flow

When a notification-eligible event is received by the relay:

```mermaid
%%{init: {
  "sequence": {
    "actorMargin": 20,
    "width": 110,
    "messageMargin": 18,
    "diagramMarginX": 8,
    "wrap": true
  }
}}%%
sequenceDiagram
    autonumber
    participant Relay as Buzz relay
    participant Gateway as Push gateway
    participant APNs as Apple Push<br/>Notification service
    participant iOS
    participant NSE as Notification service<br/>extension

    Relay->>Gateway: POST /v1/deliveries/apns<br/>opaque endpoint grant, request ID, expiry, NIP-98 authorization

    Gateway->>APNs: POST /3/device/{device-token}<br/>topic, request ID, expiry, constant mutable-content payload
    APNs-->>Gateway: 200 OK: request accepted
    Gateway-->>Relay: 200 OK: accepted status

    APNs-->>iOS: Notification: constant reconnect alert<br/>mutable-content = 1
    iOS->>NSE: Invoke extension<br/>original notification content

    NSE->>Relay: POST /query: subscription filters, limit 10<br/>NIP-98 authorization
    Relay-->>NSE: 200 OK: signed Nostr events<br/>kinds 9, 40002, 45001, or 45003

    NSE->>iOS: Complete notification: title, body, subtitle<br/>thread ID, exact-message target
```

relay → push gateway → APNs -> NSE -> Notification Center

## Known limitations

The APNs wake payload is intentionally constant and opaque: it contains
no originating community or message identifier, in keeping with the
implemented NIP-PL privacy design.

The Notification Service Extension must therefore reconnect to the relay
and resolve eligible messages after each wake. Around overlapping wakes,
timing boundaries, or resolution windows, notification presentation may
occasionally omit an expected message or display a message more than
once.

This best-effort behavior is deliberately accepted for the current
implementation and will be measured during the internal rollout to
determine whether the user experience is acceptable before any broader
deployment; the implementation does not claim exactly-once presentation.

## Validation

Live end-to-end hardware validation used an internal remotely hosted
development relay and push gateway, the APNs sandbox, and a physical
iPhone 12 mini:

- A second real Buzz client published a uniquely marked message through
the hosted relay.
- The relay matched the message and sent the constant opaque wake
through the hosted gateway. The gateway made an actual APNs request; no
`simctl push` or simulated notification was used.
- The iPhone received the notification on its lock screen. The
Notification Service Extension reconnected to the relay, fetched the
event, verified its ID and signature, and replaced the placeholder
content with the real notification title and body.
- After the app populated its shared presentation cache, a final marked
notification visibly showed the sender display name, sender avatar, and
hashtag-prefixed channel name.
- Tapping a lock-screen notification opened Buzz and exercised the
notification-response path and navigated to the corresponding message.

Final validation with a dogfood-signed artifact and production App
Attest/APNs configuration remains a release step.

## Independent pre-reviews

- **First pass:**
[Carl](buzz://message?channel=18882f4c-289f-41db-942f-81f6f8066da1&id=74ab9a93bb227f3e762568f1cf9fee66d7495b0edc3918735ff787238b9cc585)
found missing transient retries, executor-key rotation suppression,
duplicate installation renewal, and an unauthenticated challenge write
amplifier. These were resolved by [retry-safe
bootstrap](https://github.com/block/buzz/pull/6269/changes/12c66ea62)
and [authenticated renewal plus a cross-replica
quota](https://github.com/block/buzz/pull/6269/changes/8e5ece0bd).
[sol-max](buzz://message?channel=ad83385f-8e9e-4461-9a35-c1bf2e208532&id=d26d53daa4684669e2ed354638241f13f36c3a97027fe8b4dd738aff09038962)
found delegation generation burning and an edited applied migration,
resolved by [exact-generation
revocation](https://github.com/block/buzz/pull/6269/changes/c26d2159d)
and a [forward-only
migration](https://github.com/block/buzz/pull/6269/changes/956c1d099).
[k3-max](buzz://message?channel=5e46055d-a766-4065-ae25-05d1e4aaa6b2&id=d43139138a0b15f806cbdbeeedd8f69d992cadf2e805876db6fdde6a34c7eda1)
found no blockers.
- **Exact-head re-review:**
[Carl](buzz://message?channel=18882f4c-289f-41db-942f-81f6f8066da1&id=a897721673459301b0cf26e8b85a1478d7ebbb56a4621f93d774c98d395b8f68),
[sol-max](buzz://message?channel=ad83385f-8e9e-4461-9a35-c1bf2e208532&id=fb2159f709ec68f74f7b21459acd76da0e8a7c5c0f3d469f99826b0cc2380849),
and
[k3-max](buzz://message?channel=5e46055d-a766-4065-ae25-05d1e4aaa6b2&id=2ce2842910435f562e9d9cc718595848f281b122c94605e523a4b964254b8bfb)
independently returned **NO BLOCKERS** at `7eb3a650b`; k3-max also
revalidated every remediation and the endpoint-specific App Attest
enrollment bound.

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Jordan Mecom <jm@squareup.com>
2026-08-28 15:15:09 -06:00