Files
buzz/mobile/lib/shared/push/push_bridge.dart
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

295 lines
10 KiB
Dart

import 'dart:async';
import 'package:nostr/nostr.dart' as nostr;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import '../community/community.dart';
import '../deeplink/deep_link.dart';
import '../relay/relay_provider.dart';
import 'push_snapshot.dart';
const _channel = MethodChannel('buzz/push');
/// Latest APNs registration state, including callbacks replayed by iOS after
/// the Flutter method channel attaches.
final apnsDeviceToken = ValueNotifier<String?>(null);
final apnsRegistrationError = ValueNotifier<String?>(null);
final pushEndpointGrants = ValueNotifier<List<BuzzPushEndpointGrant>>([]);
final pushEndpointGrantError = ValueNotifier<String?>(null);
/// The most recent notification response waiting for app navigation.
///
/// Native iOS buffers cold-start responses until Dart asks for them. This
/// notifier also carries warm responses into the existing deep-link pipeline.
final pendingPushNotificationLink = ValueNotifier<MessageDeepLink?>(null);
MessageDeepLink? _pushNotificationLink(Object? arguments) {
if (arguments is! Map) return null;
final eventId = arguments['eventId'];
final communityId = arguments['communityId'];
final channelId = arguments['channelId'];
final threadRootId = arguments['threadRootId'];
if (eventId is! String ||
eventId.isEmpty ||
communityId is! String ||
communityId.isEmpty ||
channelId is! String ||
channelId.isEmpty ||
(threadRootId != null &&
(threadRootId is! String || threadRootId.isEmpty))) {
return null;
}
return MessageDeepLink(
communityId: communityId,
channelId: channelId,
messageId: eventId,
threadRootId: threadRootId as String?,
);
}
/// Pulls a notification response that arrived before the Flutter method
/// handler was installed.
Future<void> syncPendingBuzzPushNotificationResponse() async {
if (defaultTargetPlatform != TargetPlatform.iOS) return;
try {
final arguments = await _channel.invokeMapMethod<dynamic, dynamic>(
'takePendingNotificationResponse',
);
final link = _pushNotificationLink(arguments);
if (link != null) pendingPushNotificationLink.value = link;
} on MissingPluginException {
// Flutter tests and non-Runner embeddings do not install the native bridge.
}
}
/// Starts the independent iOS notification-authorization and APNs-registration
/// requests. Display authorization is intentionally not returned or persisted:
/// APNs registration and enrollment remain valid while display is denied.
Future<void> startBuzzPushRegistration() async {
if (!Env.pushGatewayConfigured ||
defaultTargetPlatform != TargetPlatform.iOS) {
return;
}
try {
await _channel.invokeMethod<void>('startRegistration');
} on MissingPluginException {
// Flutter tests and non-Runner embeddings do not install the native bridge.
}
}
class BuzzPushEndpointGrant {
final String relayOrigin;
final String relayPubkey;
final String installationId;
final String endpointGrant;
final String endpointHash;
final int endpointEpoch;
final int generation;
final int expiresAt;
const BuzzPushEndpointGrant({
required this.relayOrigin,
required this.relayPubkey,
required this.installationId,
required this.endpointGrant,
required this.endpointHash,
required this.endpointEpoch,
required this.generation,
required this.expiresAt,
});
factory BuzzPushEndpointGrant.fromMap(Map<dynamic, dynamic> map) {
final generation = map['generation'] as int;
return BuzzPushEndpointGrant(
relayOrigin: map['relayOrigin'] as String,
relayPubkey: map['relayPubkey'] as String,
installationId: map['installationId'] as String,
endpointGrant: map['endpointGrant'] as String,
endpointHash: map['endpointHash'] as String,
endpointEpoch: map['endpointEpoch'] as int,
generation: generation,
expiresAt: map['expiresAt'] as int,
);
}
}
Future<List<BuzzPushEndpointGrant>> readBuzzPushEndpointGrants() async {
if (!Env.pushGatewayConfigured ||
defaultTargetPlatform != TargetPlatform.iOS) {
return const [];
}
try {
final raw = await _channel.invokeListMethod<dynamic>('endpointGrants', {
'gatewayUrl': Env.pushGatewayUrl,
});
final grants = [
for (final value in raw ?? const [])
BuzzPushEndpointGrant.fromMap(value as Map<dynamic, dynamic>),
];
pushEndpointGrants.value = grants;
pushEndpointGrantError.value = null;
return grants;
} catch (error) {
pushEndpointGrantError.value = error.toString();
rethrow;
}
}
/// Enrolls the endpoint and optionally rewrites the NSE snapshot afterward.
///
/// The rewrite propagates NIP-11 `self` rotations even when the opaque grant
/// and accepted relay lease remain reusable and their generations do not move.
Future<BuzzPushEndpointGrant> enrollBuzzPush(
String relayUrl,
String gatewayUrl, {
List<Community>? communitiesForSnapshotRefresh,
}) async {
final raw = await _channel.invokeMapMethod<dynamic, dynamic>('enrollPush', {
'relayUrl': relayUrl,
'gatewayUrl': gatewayUrl,
});
if (raw == null) {
throw StateError('Native push enrollment returned no grant.');
}
final grant = BuzzPushEndpointGrant.fromMap(raw);
await readBuzzPushEndpointGrants();
if (communitiesForSnapshotRefresh != null) {
try {
await registerBuzzPushCommunitySnapshot(communitiesForSnapshotRefresh);
pushCommunitySnapshotError.value = null;
} catch (error, stackTrace) {
reportPushCommunitySnapshotError(error, stackTrace);
}
}
return grant;
}
/// Latest failure to export the community snapshot used by the iOS
/// notification service extension. Snapshot export is push enrichment and must
/// never gate authentication or community persistence.
final pushCommunitySnapshotError = ValueNotifier<String?>(null);
final pushLeaseCleanupError = ValueNotifier<String?>(null);
void reportPushCommunitySnapshotError(Object error, StackTrace stackTrace) {
pushCommunitySnapshotError.value = error.toString();
debugPrint('Push community snapshot export failed: $error');
debugPrintStack(stackTrace: stackTrace);
}
void reportPushLeaseCleanupError(Object error, StackTrace stackTrace) {
pushLeaseCleanupError.value = error.toString();
debugPrint('Push lease cleanup failed: $error');
debugPrintStack(stackTrace: stackTrace);
}
/// Restores notification presentation without reading community storage.
Future<void> restoreAgeRestrictedBuzzNotifications() async {
if (defaultTargetPlatform != TargetPlatform.iOS) return;
await _channel.invokeMethod<void>('restoreAgeRestrictedNotifications');
}
/// Removes notifications rendered before a confirmed age restriction.
Future<void> purgeAgeRestrictedBuzzNotifications() async {
if (defaultTargetPlatform != TargetPlatform.iOS) return;
await _channel.invokeMethod<void>('purgeAgeRestrictedNotifications');
}
Future<void> registerBuzzPushCommunitySnapshot(
List<Community> communities,
) async {
if (defaultTargetPlatform != TargetPlatform.iOS) return;
try {
final snapshots = [
for (final community in communities)
if (community.pushNotificationsEnabled)
BuzzPushCommunitySnapshot(
id: community.id,
name: community.name,
relayUrl: community.relayUrl,
pubkey: community.pubkey ?? pubkeyFromNsec(community.nsec),
subscriptions: community.pushSubscriptionState.authoritative,
),
];
final signingKeys = <String, String>{};
for (final community in communities) {
if (!community.pushNotificationsEnabled) continue;
final nsec = community.nsec;
if (nsec == null || nsec.isEmpty) continue;
try {
final decoded = nostr.Nip19.decode(payload: nsec);
if (decoded.prefix != nostr.Nip19Prefix.nsec ||
decoded.data.length != 64) {
continue;
}
signingKeys[community.id] = decoded.data;
} catch (_) {
// Native storage is fail-closed; malformed keys are never exported.
}
}
await _channel.invokeMethod<void>('syncPushSnapshot', {
'section': 'communities',
'communities': [for (final snapshot in snapshots) snapshot.toJson()],
'signingKeys': signingKeys,
});
} on MissingPluginException {
// Flutter tests and non-Runner embeddings do not install the native bridge.
}
}
/// Shares the organization account session with the iOS notification
/// extension, or removes it when [credential] is null. Failures propagate to
/// the account client, which owns retrying them. Organization relays
/// enforce NIP-FI, so the extension needs the session to mint the assertion
/// that lets it read the message a push refers to.
Future<void> shareOrganizationSessionWithNotifications({
required Uri origin,
required String? credential,
required List<String> relayHosts,
}) async {
if (defaultTargetPlatform != TargetPlatform.iOS) return;
try {
await _channel.invokeMethod<void>('syncPushSnapshot', {
'section': 'organizationSession',
'origin': origin.toString(),
'credential': credential,
'relayHosts': relayHosts,
});
} on MissingPluginException {
// Flutter tests and non-Runner embeddings do not install the native bridge.
}
}
void installBuzzPushMethodHandler() {
_channel.setMethodCallHandler((call) async {
switch (call.method) {
case 'apnsTokenChanged':
final args = call.arguments;
if (args is Map) {
final token = args['token'];
if (token is String && token.isNotEmpty) {
apnsDeviceToken.value = token;
apnsRegistrationError.value = null;
}
}
return null;
case 'apnsRegistrationFailed':
final args = call.arguments;
final message = args is Map ? args['message'] : null;
apnsRegistrationError.value = message is String && message.isNotEmpty
? message
: 'APNs registration failed';
debugPrint('APNs registration failed: ${apnsRegistrationError.value}');
return null;
case 'notificationOpened':
final link = _pushNotificationLink(call.arguments);
if (link == null) return 'ignored';
pendingPushNotificationLink.value = link;
return 'handled';
default:
throw MissingPluginException('Unknown buzz/push method ${call.method}');
}
});
}