Skip to content

fix(mobile): hosted NIP-FI assertions (backport #9056, #9069, #9134, #9225, #9228 to 0.27) - #9232

Merged
brow merged 6 commits into
mobile-v0.27from
tomb/mobile-v0.27-backport-nip-fi
Nov 7, 2026
Merged

brow merged 6 commits into
mobile-v0.27from
tomb/mobile-v0.27-backport-nip-fi

Conversation

@brow

@brow brow commented Nov 7, 2026

Copy link
Copy Markdown
Collaborator

Backport to mobile-v0.27 of the mobile fixes that let iOS release builds turn on hosted NIP-FI (BUZZ_HOSTED_RELAY_AUTH) for the Nov 10 launch. squareup/buzz-releases#142 turns the flag on for 0.27.1 candidates after this lands.

Picks

Each is git cherry-pick -x --signoff of the squash commit on main, in this order:

One 0.27-only commit, test(mobile): drop org key backup checks from the #9069 backport: 0.27 has no organization key backup (#9029), so the backported organization relay tests keep only their assertion and cache checks. No production code differs from main's versions.

All picks applied without conflicts.

Risk

Hosted assertions are sent only when the build sets BUZZ_HOSTED_RELAY_AUTH, which current App Store builds don't. With the flag off, the one new behavior from #9228 is clearing a hosted session an earlier build might have shared with the notification extension. Organization relays keep their issuer. Each PR's description on main covers its own testing.

Testing

  • dart format --set-exit-if-changed, flutter analyze: clean.
  • Full flutter test with CI's BUZZ_PUSH_GATEWAY_URL define: all pass (4,386).
  • CI's configured-auth suite (BUZZ_AUTH_DOMAINS=buzz.xyz,builderlab.xyz,builderlabstaging.xyz) plus test/shared/hosted, test/features/onboarding and test/shared/verified_profiles: all pass (355).
  • swift test in ios/BuzzPushKit: all pass (110 XCTest, 70 Swift Testing).
  • Automated app run: an iOS simulator build of this branch with the App Store defines plus BUZZ_HOSTED_RELAY_AUTH=true, installed over a signed-in hosted account. The hosted community connects with no denial banner, the channel list loads, and a channel's history, avatars and media load. The notification extension can't be exercised on the simulator; BuzzPushKit tests cover it.

🤖 Generated with Claude Code

brow and others added 6 commits November 6, 2026 17:19
…9056)

On iOS, custom emoji in the native emoji picker often render as the
sparkle fallback icon instead of the image.

The native picker downloads custom emoji with `URLSession` and asks
Flutter for request headers over the `mediaHeaders` channel call. That
call returned only the media read `Authorization` header. Hosted relays
also require the `Nostr-Federated-Identity` assertion, which the Flutter
image path gets from `HostedRelayHttpClient`, so relay-hosted emoji get
a 401 and the picker falls back to sparkles. Emoji hosted elsewhere
still load, which is why only some are missing.

The `mediaHeaders` bridge now also includes the hosted relay assertion
via a new `HostedRelayAuth.headersForUrl`, scoped to the relay's own
origin like `HostedRelayHttpClient`. The native loader refuses redirects
for requests that carry the assertion, matching the Dart client, so the
token can't be forwarded to another host.

The iOS reactions sheet had the same gap: it passes avatar and custom
emoji URLs to native views with only the `Authorization` header. A new
`nativeMediaHeadersProvider` combines both headers for any native media
consumer, and the reactions sheet uses it.

### Validation

- Added tests: the native picker's `mediaHeaders` call returns the relay
assertion for relay media and no headers for an external emoji URL;
`headersForUrl` returns the assertion only for the relay origin (not
another host, subdomain or port). The reactions sheet passes the
combined headers for avatars and custom emoji, and
`nativeMediaHeadersProvider` merges both headers only for relay URLs.
- Focused existing validation: the emoji picker and organization relay
auth suites pass, and `flutter analyze` is clean. In the full mobile
suite, the only failures are 10 in `notification_toggle_test`,
`push_bootstrap_test` and `push_bridge_test`, and they fail the same way
on `main`.
- Automated full-app simulator run (iPhone 17 Pro, iOS 27, debug build
of this branch with hosted auth enabled, signed in to two hosted
communities through a cloned simulator keychain, driven with AXe).
Covered: launch signed in, channel and DM lists, opening a channel and a
DM, the native emoji picker (its Custom section shows the emoji as an
image, not the sparkle fallback), sending a custom emoji message and
seeing it render, reacting with the custom emoji (shown in the
quick-reaction row and as a reaction pill), opening a thread, switching
communities, avatars and community icons, background and resume, and a
cold relaunch. Result: no crashes or regressions seen. Screenshots are
omitted because they show real account and community names.
- Limitations: this run does not reproduce the original bug. The hosted
community's custom emoji also loaded in a temporary build with the relay
assertion removed from the `mediaHeaders` bridge, so its emoji URL
evidently doesn't need the assertion, and the run doesn't prove the fix.
The reactions detail sheet could not be reached because simulated long
presses on the pill opened the message menu, and inline image
attachments were not exercised. A device build against a community whose
emoji are relay-hosted is still needed to confirm the fix.

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

---------

Signed-off-by: Tom Brow <[email protected]>
Co-authored-by: Tom Brow <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
(cherry picked from commit 6b8a72e)
Signed-off-by: Tom Brow <[email protected]>
…one paths (#9069)

Follow-up to #9056, from an audit for
other places that miss the hosted relay assertion. Hosted and
organization relays require `Nostr-Federated-Identity` on every
non-exempt HTTP request and WebSocket upgrade. The Dart
`HostedRelayHttpClient` adds it, but anything that downloads or connects
outside that client has to add it itself. The notification service
extension and the iOS emoji picker both missed it, and these places do
too:

- **Voice notes** (`just_audio`, mainly Android) and **video preview
first frames** fetch media natively with only the Blossom
`Authorization` header. Both now use `nativeMediaHeadersProvider` from
#9056.
- **Push-lease tombstones**: removing a community, and the durable
revocation outbox, open a one-off relay socket with no headers. A hosted
relay refuses the upgrade, so the lease is never revoked and the outbox
keeps retrying. Both now ask `HostedRelayAuth` for that community's own
assertion, because the community being removed may not be the active
session.
- **Notification avatars** (iOS notification service extension):
relay-hosted sender avatars were fetched with Blossom auth only. The
loader now takes the same `BuzzRelayAssertionIssuer` the resolver uses
for `/query`. Signed requests already refuse redirects.

The iOS Me-chat agent picker also fetched avatars with no headers, but
#9079 removed that native screen, so
this PR no longer touches it.

- Added tests, each checked to fail when the code it guards is reverted:
- A queued push-lease revocation and a community-removal tombstone both
send `Nostr-Federated-Identity` on the WebSocket upgrade, as background
requests. Runs against a local relay socket that handles NIP-42 auth.
- A notification avatar on an organization relay is downloaded with the
issued assertion (BuzzPushKit).
- Issuing for a second community keeps the first one's cached assertion.
A background request records no failure, never backs up the key, and its
failure never reaches the active community's reconnect waiting on it.
- Background issuance never binds an unbound account to its key,
bypasses the shared assertion cache (each attempt gets a fresh
assertion, and foreground use never inherits one that skipped the key
backup), and its failure is never handed to a foreground caller waiting
on it. When it can't get an assertion it sends none, so a non-enforcing
relay still accepts the tombstone.
- A notification avatar whose organization session is replaced
mid-download is refused and dropped from the cache (BuzzPushKit).
- Voice notes and posterless video previews renew an expired assertion
before the native fetch, and fall back to cached headers if renewal
fails. A voice-note attachment journey binds the production provider.
- Cached reads never present a signed-out or replaced account's
assertion.
- `flutter analyze` is clean. `swift test` passes for the BuzzPushKit
avatar loader tests, and `flutter build ios --simulator --debug`
succeeds.
- Full mobile suite (`just mobile-check` and `just mobile-test`) passes
at 2c84940.
- Automated full-app run on an iOS simulator (debug build of this PR
before it was rebuilt on main, signed in to the internal Builderlab
community with two more communities saved). Passed: launch and channel
list, image attachments and avatars in a channel, New chat sheet kept
open for about 80 seconds before the agent list was opened (all agent
avatars loaded, so headers are requested per download), dismiss and
reopen, background and resume, switching to a second community (its icon
and avatars loaded), and removing a community. The native New chat sheet
is unreachable in the normal flow on this branch (a new chat opens the
Flutter draft page; the native sheet only opens when resuming an
unfinished chat), so that check ran with a temporary local bypass that
was reverted. Limits: none of these communities requires the hosted
relay assertion in this build, so the run shows the Swift-to-Dart header
bridge, the per-relay cache and the tombstone path cause no regression,
not that the assertion is sent or accepted. No voice note, video preview
or deleted message was reachable. Screenshots are omitted because they
show internal channel content.
- Limitations: no end-to-end run against a hosted relay. Simulator
sign-in to a hosted relay needs SSO, so a hosted-relay run needs a
device build.

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

---------

Signed-off-by: Tom Brow <[email protected]>
Co-authored-by: Tom Brow <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
(cherry picked from commit 97ede70)
Signed-off-by: Tom Brow <[email protected]>
…ed-session avatars (#9134)

Follow-up to [#9069 media
auth](#9069), which merged at its
approved head. This PR carries the fixes for the last review round on
that PR.

### Changes

- **Native media reads renew an expired assertion** (Carl's [recommended
blocker](#9069 (comment))).
Voice notes and posterless video previews hand headers to native code.
They now use `freshNativeMediaHeadersProvider`, which awaits
`HostedRelayAuth.headersForUrl` and so asks for a new assertion once the
cached one has expired. Before, while the socket stayed up they sent
none and the relay refused the read. If the renewal fails, the read
falls back to cached headers and the relay decides. The voice-note
player's `headers` callback is now `FutureOr`. Both the iOS
authenticated download and the Android native load await it, then
recheck their generation, so a cancel during a slow renewal never starts
the stale load.
- **Notification avatars drop a retired organization session** ([review
thread](#9069 (comment))).
When an avatar download finishes after the organization session that
issued its assertion was replaced, BuzzPushKit removes the cached
response and refuses the avatar.
- **Test:** cached reads never present a signed-out or replaced
account's assertion ([optional
note](#9069 (comment))).

Not changed: native reaction-detail avatars still use the cached
assertion. After it expires they fall back to initials.

### Validation

- Each new test was checked to fail with the code it guards reverted:
- `replacedOrganizationSessionRefusesAvatar` (BuzzPushKit): a session
replaced mid-download must be refused.
- `fresh native media headers renew an expired assertion`: covers
renewal and the fallback when renewal fails.
- `remote voice notes renew an expired relay assertion`: a journey
through `VoiceNoteAttachment`; it fails when the attachment is switched
back to the synchronous provider.
- `Android defers remote auth until playback starts`: now cancels during
a slow renewal and expects no native load.
- `an issued assertion serves cached reads for the relay`: now signs out
and restores a replacement account. It fails without the account check.
- `swift test` (69 tests) passes. The full mobile suite (`just
mobile-install mobile-check mobile-test`) passes with these commits on
main as of [#9069 media auth](#9069).
- The automated full-app simulator run from [#9069 media
auth](#9069) covers the surrounding
flows. This change is limited to the header source for voice notes and
video previews, and none of the test communities enforce the hosted
assertion, so a simulator run would not show the difference. No
end-to-end run against a relay that enforces the assertion.

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

---------

Signed-off-by: Tom Brow <[email protected]>
Co-authored-by: Tom Brow <[email protected]>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 1b9b307)
Signed-off-by: Tom Brow <[email protected]>
…ired (#9225)

Port the desktop NIP-FI requirement rule to mobile: hosted relay connections request an account assertion only when the relay's NIP-11 document requires NIP-FI, so communities that have not enabled it keep connecting with BUZZ_HOSTED_RELAY_AUTH on.

Signed-off-by: Tom Brow <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit b58ee08)
Signed-off-by: Tom Brow <[email protected]>
0.27 has no organization key backup (#9029), so the backported
organization relay tests keep their assertion and cache checks only.

Signed-off-by: Tom Brow <[email protected]>
…#9228)

Share the hosted Builderlab session with the iOS notification extension so it can mint hosted NIP-FI assertions. The extension queries a hosted relay without an assertion first and mints only after a NIP-FI challenge, for a key the account is already bound to. Credential headers never follow redirects, and flag-off builds clear a previously shared session.

Signed-off-by: Tom Brow <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 794ee64)
Signed-off-by: Tom Brow <[email protected]>
@brow
brow marked this pull request as ready for review November 7, 2026 03:24
@brow
brow requested a review from a team as a code owner November 7, 2026 03:24
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-07T03:28:38.661522Z 752d97f Draft marked ready
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@kalvinnchau kalvinnchau 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.

🤖 Approving at 752d97f as a backport check, not a full re-review. All five sources (#9056, #9069, #9134, #9225, #9228) are merged on main.

  • git range-diff of each pick against its main squash commit: the only differences are cherry-pick trailers and context lines for organization key backup (#9029), which mobile-v0.27 does not have (organizationBackup / organization_key_backup have no matches in mobile/lib on the base or the head).
  • c1d6489 drops only the key-backup expectations from organization_relay_auth_test.dart. The assertion and cache checks stay.
  • At 752d97f: flutter analyze is clean. flutter test test/shared/hosted/ test/shared/push/ test/shared/verified_profiles/ test/features/onboarding/hosted_journeys_test.dart with BUZZ_AUTH_DOMAINS and BUZZ_PUSH_GATEWAY_URL passes (350). swift test in ios/BuzzPushKit passes (110 XCTest + 70 Swift Testing).

Scores: minimalness 10/10, elegance 9/10 (inherits the sources), correctness 9/10.

@brow
brow merged commit 07aacd1 into mobile-v0.27 Nov 7, 2026
45 checks passed
@brow
brow deleted the tomb/mobile-v0.27-backport-nip-fi branch November 7, 2026 03:42
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.

3 participants