Skip to content

feat(mobile): push notifications MVP - #6269

Open
brow wants to merge 30 commits into
mainfrom
codex/ios-push-internal
Open

feat(mobile): push notifications MVP#6269
brow wants to merge 30 commits into
mainfrom
codex/ios-push-internal

Conversation

@brow

@brow brow commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

This PR implements MVP, iOS-only, NIP-PL-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:

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
Loading

Push-time flow

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

%%{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
Loading

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 found missing transient retries, executor-key rotation suppression, duplicate installation renewal, and an unauthenticated challenge write amplifier. These were resolved by retry-safe bootstrap and authenticated renewal plus a cross-replica quota. sol-max found delegation generation burning and an edited applied migration, resolved by exact-generation revocation and a forward-only migration. k3-max found no blockers.
  • Exact-head re-review: Carl, sol-max, and k3-max 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>
@brow
brow force-pushed the codex/ios-push-internal branch from c7a089a to d69b89c Compare August 18, 2026 23:25
@brow brow changed the title Add internal iOS push notification MVP feat(mobile): push notifications MVP Aug 19, 2026
brow added 3 commits August 20, 2026 13:48
Signed-off-by: Tom Brow <tomb@block.xyz>
…ifications-pr

* origin/main: (33 commits)
  fix(hooks): scope pre-push lanes to branch merge-base diff (#6423)
  Enforce a three-day dependency cooldown (#6426)
  perf(desktop): resolve references without directory scans (#6328)
  feat(llm): stamp thinking effort on call-completed log line (#6424)
  Fix cross-owner relay agent mentions in owner-only builds (#6338)
  feat(cli): accept Buzz message links for thread reads (#6359)
  feat(workflows): add workflow editor (#6248)
  fix(desktop): preserve huddle speech boundaries (#6397)
  test(desktop): use a wordlist-safe separator in passphrase word-count test (#6356)
  fix(models): curate Databricks alias-aware labels for 5 missing endpoints (#6360)
  fix(acp): guard against unrequested public relay skills (#6394)
  feat(desktop): refine context-aware Projects collaboration (#6396)
  fix(desktop): distinguish duplicate agent devices (#6337)
  feat(desktop): close Buzz window with Cmd+W (#6314)
  refactor(prompt): simplify Buzz agent guidance (#6340)
  feat(desktop): make Projects workspaces selectable (#6368)
  Add Buzz-native collaboration benchmarks (#6264)
  Polish mobile timeline and emoji interactions (#6297)
  feat(desktop): make the Projects overview follow the selected section (#6335)
  refactor(desktop): coordinate TTS playback (#6341)
  ...

Signed-off-by: Tom Brow <tomb@block.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
@lezdoors

This comment was marked as outdated.

@lezdoors

This comment was marked as outdated.

brow and others added 20 commits August 21, 2026 21:09
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
Ai-assisted: true
…ifications-pr

* origin/main: (38 commits)
  fix(composer): preserve caret when inserting mentions mid-message (#6531)
  chore(deps): update rust crate async-trait to v0.1.92 (#6094)
  chore(deps): update dependency sonner to v2.0.8 (#6093)
  chore(deps): update rust crate http-body-util to v0.1.4 (#5452)
  chore(deps): update rust crate http to v1.4.2 (#5451)
  chore(deps): update rust crate futures-util to v0.3.33 (#5448)
  chore(deps): update rust crate futures to v0.3.33 (#5445)
  chore(deps): update dependency @tauri-apps/api to v2.11.1 (#5444)
  chore(deps): update ubuntu:24.04 docker digest to 561618e (#5442)
  chore(deps): update swatinem/rust-cache digest to 6323deb (#5441)
  fix(desktop): restore true zoom by scaling the root rem (#6514)
  chore(desktop): drop unused ORIGINAL_CONTENT from empty-edit-delete spec (#6517)
  feat(workflows): clarify workflow setup and activation (#6470)
  perf(desktop): stop the Projects fan refetching on re-entry and running after leave (#6458)
  perf(desktop): keep the member roster off the channel-switch path (#6456)
  fix(deletion): allow IRSA S3 credentials (#6495)
  docs(nips): comprehensive NIP-FI — core + claimable profiles (EDGE/LIFECYCLE/DELEG/CONF) (#5946)
  fix(benchmarks): wait for scripted event delivery (#6487)
  Polish mobile channel navigation and message sends (#6488)
  Clarify huddle message destination (#6496)
  ...

Signed-off-by: Tom Brow <tomb@block.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
Ai-assisted: true
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
Ai-assisted: true
Signed-off-by: Tom Brow <tomb@block.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
Ai-assisted: true
…ifications-pr

* origin/main:
  fix(desktop): emit singular `mention` feed category so alerts route correctly (#6665)
  fix(mobile): recover stale and shuffled messages (#6691)
  feat(mobile): browse and join open channels (#6243)
  show mention counts in channel notifications (#6696)
  fix(desktop): hide selection formatting tray on composer right-click (#6683)
  fix(desktop): stabilize members dialog scrolling (#6670)
  fix(desktop): keep member runtime status off the UI thread (#6445)
  perf(desktop): persist channel heads, collapse thread reads and reply sends (#6572)
  Downgrade desktop Huddles to audio protocol v2 (#6610)
  Polish Huddle participant interactions (#6312)
  Downgrade mobile Huddles to audio protocol v2 (#6558)
  perf(desktop): make the Projects surface render-cheap (#6460)
  refactor(acp): clarify agent prompt sections (#6501)
  Add mobile Huddles voice MVP (#6056)
  feat(desktop-messages): keep agents addressed across messages (#6315)
  fix(desktop): remove Buzz entity link previews (#6512)

Signed-off-by: Tom Brow <tomb@block.xyz>

# Conflicts:
#	mobile/lib/features/channels/channels_provider.dart
#	mobile/lib/shared/auth/auth_provider.dart
#	mobile/lib/shared/community/community_provider.dart
#	mobile/test/shared/auth/auth_provider_test.dart
#	mobile/test/shared/community/community_provider_test.dart
#	scripts/mobile-worktree-overrides.sh
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
Ai-assisted: true
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
Ai-assisted: true
Signed-off-by: Tom Brow <tomb@block.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
Ai-assisted: true
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
Ai-assisted: true
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
Ai-assisted: true
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
Ai-assisted: true
…ifications-pr

* origin/main:
  Centralize replaceable event persistence (#6660)
  feat(workflows): discover trigger filter values (#6712)
  feat(desktop): simplify the message action rail (#6529)
  fix(desktop): restore icon-only remote marker (#6491)
  fix(ci): prevent poisoned Rust caches (#6618)
  docs(security): route reports through private advisories (#6728)
  fix(composer): wrap Buzz chip labels without orphaning icons (#6581)
  fix(desktop): bound thread /query and surface load errors, not false-empty (#6447)

Signed-off-by: Tom Brow <tomb@block.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
Ai-assisted: true
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
Ai-assisted: true
Signed-off-by: Tom Brow <tomb@block.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
Ai-assisted: true
@brow
brow marked this pull request as ready for review August 26, 2026 02:41
@brow
brow requested a review from a team as a code owner August 26, 2026 02:41

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5a5e767ddf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread mobile/lib/shared/push/push_bootstrap.dart Outdated
Comment thread mobile/lib/shared/push/push_bootstrap.dart Outdated
Comment thread mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift Outdated
brow and others added 6 commits August 25, 2026 19:48
Signed-off-by: Tom Brow <tomb@block.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
Ai-assisted: true
Signed-off-by: Tom Brow <tomb@block.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
Ai-assisted: true
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
Ai-assisted: true

@jedwards27 jedwards27 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verdict: REQUEST CHANGES

Reviewed: 52621c09bea503f4d5860030dfabfaf9ade71bfa..7eb3a650bed2b6980e3c999e4a3208162eb223a4 (exact live head rechecked before submission)

Risk: critical — this adds iOS permission/consent behavior, APNs token custody, App Attest authority, relay/gateway persistence, and multi-step recovery state machines.

Behavior/contracts traced: authenticated capability discovery → iOS authorization/APNs registration → App Attest installation → relay-key delegation → encrypted lease publication → relay matching/durable delivery → APNs/NSE resolution → tap navigation; including generation monotonicity, retries, persistence, revocation, and release boundaries.

Blocking findings

P1 — A committed installation can be orphaned until expiry

For a new endpoint, BuzzDevPushEnrollmentDriver creates and stores a fresh App Attest key ID, commits /v1/installations, and only afterward creates the delegation and persists the installation handle/grant (mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift:248-255,455-490,493-549). If installation creation succeeds but delegation, response handling, or store.save fails, the client has no durable installation handle from which to resume. Its retry generates another App Attest key and attempts another installation, while the gateway rejects a live row with the same (app_profile, token_fingerprint) (crates/buzz-push-gateway/src/postgres.rs:165-191). There is no reclaim/lookup, rollback, or pending-installation journal, so an ordinary partial failure can disable push for that APNs token until server expiry or manual cleanup.

Author action: make installation creation recoverable/idempotent across every post-commit failure. Durably journal the prepared key and returned installation before delegation and resume it, or provide an authenticated reclaim/idempotency contract. Add fault injection proving that installation commit followed by delegation/local-save failure recovers on retry without waiting for expiry.

P1 — Relay acceptance followed by local persistence failure wedges lease generation

BuzzPushBootstrap._publish computes (acceptedGeneration ?? 0) + 1, awaits relay acceptance, and only then stores the accepted generation (mobile/lib/shared/push/push_bootstrap.dart:238-267; mobile/lib/shared/community/community_provider.dart:324-344). If that local write fails after the relay OK, the next retry derives the same generation from stale state. The relay rejects it because accepted generations must strictly increase (crates/buzz-db/src/push.rs:287-307; crates/buzz-relay/src/handlers/ingest.rs:2918-2929). The five-second retry has no accepted-generation reconciliation, so it repeats a permanently stale value.

Author action: make relay-commit/local-save partial success recoverable: reserve durable generation state before publication with safe retry advancement, or reconcile the relay watermark and retry strictly above it. Add a regression where relay generation N commits, local persistence fails, and the next attempt reaches accepted local/relay state at generation > N.

P2 — Passive connection activates push without explicit user opt-in

A valid push descriptor on an authenticated connection immediately invokes native registration (mobile/lib/shared/push/push_bootstrap.dart:107-120), which requests alert/badge/sound permission and registers with APNs (mobile/ios/Runner/AppDelegate.swift:361-375). Capability discovery also synthesizes and persists non-empty desired subscriptions without a user choice (mobile/lib/app.dart:325-332; mobile/lib/shared/push/push_subscription_provider.dart:16-35,38-56), after which bootstrap enrolls and publishes/renews the lease (mobile/lib/shared/push/push_bootstrap.dart:144-200,231-268). A search of mobile/lib and mobile/lib/features/settings found no persisted opt-in gate or notification preference surface.

That contradicts the repository contracts: VISION.md:129-133 says “Zero is the default. You opt in to noise, not out,” and docs/nips/NIP-PL.md:153 says clients MUST NOT register a lease/subscription absent explicit user opt-in. It also means denying display permission does not stop gateway enrollment or a renewable server-side wake subscription.

Author action: add explicit persisted user-controlled opt-in, default off, and gate display authorization, APNs/gateway enrollment, and non-empty lease publication/renewal. Opt-out must stop renewal and exercise the higher-generation tombstone/revocation path; OS denial must remain distinct from enabled. Add regressions proving capability alone causes zero registration/enrollment/lease calls, opt-in enables them, and opt-out revokes and survives restart.

Verification owner: author for deterministic recovery/consent tests; code reviewer for exact-head re-review; release owner for signed dogfood physical-device APNs/App Attest proof.

Validation

At clean 7eb3a650bed2b6980e3c999e4a3208162eb223a4:

  • PASS — git diff --check 52621c09bea503f4d5860030dfabfaf9ade71bfa..HEAD
  • PASS — just mobile-test: 1,911 tests
  • PASS — swift test --package-path mobile/ios/BuzzPushKit: 66 XCTest + 18 Swift Testing tests
  • PASS — cargo test -p buzz-push-gateway: 43 passed, 9 infrastructure/live tests ignored
  • Exact-head required GitHub checks queried: all reported required lanes passed, including Mobile, Mobile Swift, Rust, security, relay E2E, and backend integration.
  • An independent selected-package Rust run reported 919 relay passes plus one repeatable mesh-demo 504 failure; no PR causality was established, so that is an integration-owner confidence gap rather than an additional author defect.

Green suites do not exercise the three failure/consent contracts above.

Manual/native evidence and residual risk

The PR body reports physical iPhone 12 mini sandbox delivery and tap testing, but this review did not have an inspectable exact-head signed artifact/video/semantic receipt. Production App Attest/APNs, lock-screen/Focus privacy permutations, VoiceOver, foreground/background/terminated lifecycle, duplicate/omission frequency, and a real notification tap remain release-owned exact-artifact checks. Those gaps are not the basis for REQUEST CHANGES; the three source-established defects are.

— :bot: Jude’s code review agent

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.

5 participants